mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-06 08:50:50 +08:00
refactor(infra): directory structure (#4615)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Paco Coursey
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
# copied directly from https://github.com/pacocoursey/cmdk
|
||||
|
||||
will remove after a new CMDK version is published to npm
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@affine/cmdk",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.tsx",
|
||||
"module": "./src/index.tsx",
|
||||
"devDependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"version": "0.10.0-canary.1"
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
// The scores are arranged so that a continuous match of characters will
|
||||
// result in a total score of 1.
|
||||
//
|
||||
// The best case, this character is a match, and either this is the start
|
||||
// of the string, or the previous character was also a match.
|
||||
var SCORE_CONTINUE_MATCH = 1,
|
||||
// A new match at the start of a word scores better than a new match
|
||||
// elsewhere as it's more likely that the user will type the starts
|
||||
// of fragments.
|
||||
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
|
||||
// hyphens, etc.
|
||||
SCORE_SPACE_WORD_JUMP = 0.9,
|
||||
SCORE_NON_SPACE_WORD_JUMP = 0.8,
|
||||
// Any other match isn't ideal, but we include it for completeness.
|
||||
SCORE_CHARACTER_JUMP = 0.17,
|
||||
// If the user transposed two letters, it should be significantly penalized.
|
||||
//
|
||||
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
|
||||
SCORE_TRANSPOSITION = 0.1,
|
||||
// The goodness of a match should decay slightly with each missing
|
||||
// character.
|
||||
//
|
||||
// i.e. "bad" is more likely than "bard" when "bd" is typed.
|
||||
//
|
||||
// This will not change the order of suggestions based on SCORE_* until
|
||||
// 100 characters are inserted between matches.
|
||||
PENALTY_SKIPPED = 0.999,
|
||||
// The goodness of an exact-case match should be higher than a
|
||||
// case-insensitive match by a small amount.
|
||||
//
|
||||
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
|
||||
//
|
||||
// This will not change the order of suggestions based on SCORE_* until
|
||||
// 1000 characters are inserted between matches.
|
||||
PENALTY_CASE_MISMATCH = 0.9999,
|
||||
// Match higher for letters closer to the beginning of the word
|
||||
PENALTY_DISTANCE_FROM_START = 0.9,
|
||||
// If the word has more characters than the user typed, it should
|
||||
// be penalised slightly.
|
||||
//
|
||||
// i.e. "html" is more likely than "html5" if I type "html".
|
||||
//
|
||||
// However, it may well be the case that there's a sensible secondary
|
||||
// ordering (like alphabetical) that it makes sense to rely on when
|
||||
// there are many prefix matches, so we don't make the penalty increase
|
||||
// with the number of tokens.
|
||||
PENALTY_NOT_COMPLETE = 0.99;
|
||||
|
||||
var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/,
|
||||
COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g,
|
||||
IS_SPACE_REGEXP = /[\s-]/,
|
||||
COUNT_SPACE_REGEXP = /[\s-]/g;
|
||||
|
||||
function commandScoreInner(
|
||||
string,
|
||||
abbreviation,
|
||||
lowerString,
|
||||
lowerAbbreviation,
|
||||
stringIndex,
|
||||
abbreviationIndex,
|
||||
memoizedResults
|
||||
) {
|
||||
if (abbreviationIndex === abbreviation.length) {
|
||||
if (stringIndex === string.length) {
|
||||
return SCORE_CONTINUE_MATCH;
|
||||
}
|
||||
return PENALTY_NOT_COMPLETE;
|
||||
}
|
||||
|
||||
var memoizeKey = `${stringIndex},${abbreviationIndex}`;
|
||||
if (memoizedResults[memoizeKey] !== undefined) {
|
||||
return memoizedResults[memoizeKey];
|
||||
}
|
||||
|
||||
var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
|
||||
var index = lowerString.indexOf(abbreviationChar, stringIndex);
|
||||
var highScore = 0;
|
||||
|
||||
var score, transposedScore, wordBreaks, spaceBreaks;
|
||||
|
||||
while (index >= 0) {
|
||||
score = commandScoreInner(
|
||||
string,
|
||||
abbreviation,
|
||||
lowerString,
|
||||
lowerAbbreviation,
|
||||
index + 1,
|
||||
abbreviationIndex + 1,
|
||||
memoizedResults
|
||||
);
|
||||
if (score > highScore) {
|
||||
if (index === stringIndex) {
|
||||
score *= SCORE_CONTINUE_MATCH;
|
||||
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
|
||||
score *= SCORE_NON_SPACE_WORD_JUMP;
|
||||
wordBreaks = string
|
||||
.slice(stringIndex, index - 1)
|
||||
.match(COUNT_GAPS_REGEXP);
|
||||
if (wordBreaks && stringIndex > 0) {
|
||||
score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length);
|
||||
}
|
||||
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
|
||||
score *= SCORE_SPACE_WORD_JUMP;
|
||||
spaceBreaks = string
|
||||
.slice(stringIndex, index - 1)
|
||||
.match(COUNT_SPACE_REGEXP);
|
||||
if (spaceBreaks && stringIndex > 0) {
|
||||
score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length);
|
||||
}
|
||||
} else {
|
||||
score *= SCORE_CHARACTER_JUMP;
|
||||
if (stringIndex > 0) {
|
||||
score *= Math.pow(PENALTY_SKIPPED, index - stringIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
|
||||
score *= PENALTY_CASE_MISMATCH;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(score < SCORE_TRANSPOSITION &&
|
||||
lowerString.charAt(index - 1) ===
|
||||
lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
|
||||
(lowerAbbreviation.charAt(abbreviationIndex + 1) ===
|
||||
lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
|
||||
lowerString.charAt(index - 1) !==
|
||||
lowerAbbreviation.charAt(abbreviationIndex))
|
||||
) {
|
||||
transposedScore = commandScoreInner(
|
||||
string,
|
||||
abbreviation,
|
||||
lowerString,
|
||||
lowerAbbreviation,
|
||||
index + 1,
|
||||
abbreviationIndex + 2,
|
||||
memoizedResults
|
||||
);
|
||||
|
||||
if (transposedScore * SCORE_TRANSPOSITION > score) {
|
||||
score = transposedScore * SCORE_TRANSPOSITION;
|
||||
}
|
||||
}
|
||||
|
||||
if (score > highScore) {
|
||||
highScore = score;
|
||||
}
|
||||
|
||||
index = lowerString.indexOf(abbreviationChar, index + 1);
|
||||
}
|
||||
|
||||
memoizedResults[memoizeKey] = highScore;
|
||||
return highScore;
|
||||
}
|
||||
|
||||
function formatInput(string) {
|
||||
// convert all valid space characters to space so they match each other
|
||||
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ');
|
||||
}
|
||||
|
||||
export function commandScore(string: string, abbreviation: string): number {
|
||||
/* NOTE:
|
||||
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
|
||||
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
|
||||
*/
|
||||
return commandScoreInner(
|
||||
string,
|
||||
abbreviation,
|
||||
formatInput(string),
|
||||
formatInput(abbreviation),
|
||||
0,
|
||||
0,
|
||||
{}
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
lib
|
||||
@@ -0,0 +1,3 @@
|
||||
# @affine/debug
|
||||
|
||||
A common debug interface for packages in this repository.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@affine/debug",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.9",
|
||||
"vitest": "0.34.6"
|
||||
},
|
||||
"version": "0.10.0-canary.1"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { DebugLogger } from '..';
|
||||
|
||||
describe('debug', () => {
|
||||
test('disabled', () => {
|
||||
const logger = new DebugLogger('test');
|
||||
logger.enabled = false;
|
||||
for (const level of ['debug', 'info', 'warn', 'error'] as const) {
|
||||
const fn = vi.fn();
|
||||
vi.spyOn(globalThis.console, level).mockImplementation(fn);
|
||||
expect(logger.enabled).toBe(false);
|
||||
expect(fn).not.toBeCalled();
|
||||
logger[level]('test');
|
||||
expect(fn, level).not.toBeCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test('log', () => {
|
||||
const logger = new DebugLogger('test');
|
||||
logger.enabled = true;
|
||||
for (const level of ['debug', 'info', 'warn', 'error'] as const) {
|
||||
const fn = vi.fn();
|
||||
vi.spyOn(globalThis.console, level).mockImplementation(fn);
|
||||
expect(logger.enabled).toBe(true);
|
||||
expect(fn).not.toBeCalled();
|
||||
logger[level]('test');
|
||||
expect(fn, level).toBeCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import debug from 'debug';
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const SESSION_KEY = 'affine:debug';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// enable debug logs if the URL search string contains `debug`
|
||||
// e.g. http://localhost:3000/?debug
|
||||
if (window.location.search.includes('debug')) {
|
||||
// enable debug logs for the current session
|
||||
// since the query string may be removed by the browser after navigations,
|
||||
// we need to store the debug flag in sessionStorage
|
||||
sessionStorage.setItem(SESSION_KEY, 'true');
|
||||
}
|
||||
if (sessionStorage.getItem(SESSION_KEY) === 'true') {
|
||||
// enable all debug logs by default
|
||||
debug.enable('*');
|
||||
console.warn('Debug logs enabled');
|
||||
}
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
debug.enable('*');
|
||||
console.warn('Debug logs enabled');
|
||||
}
|
||||
}
|
||||
|
||||
export class DebugLogger {
|
||||
private _debug: debug.Debugger;
|
||||
|
||||
constructor(namespace: string) {
|
||||
this._debug = debug(namespace);
|
||||
}
|
||||
|
||||
set enabled(enabled: boolean) {
|
||||
this._debug.enabled = enabled;
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this._debug.enabled;
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]) {
|
||||
this.log('debug', message, ...args);
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
this.log('info', message, ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]) {
|
||||
this.log('warn', message, ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]) {
|
||||
this.log('error', message, ...args);
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...args: any[]) {
|
||||
this._debug.log = console[level].bind(console);
|
||||
this._debug(`[${level.toUpperCase()}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
lib
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@affine/env",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"vitest": "0.34.6",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"exports": {
|
||||
"./automation": "./src/automation.ts",
|
||||
"./global": "./src/global.ts",
|
||||
"./constant": "./src/constant.ts",
|
||||
"./workspace": "./src/workspace.ts",
|
||||
"./workspace/legacy-cloud": "./src/workspace/legacy-cloud/index.ts",
|
||||
"./filter": "./src/filter.ts",
|
||||
"./blocksuite": "./src/blocksuite/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/global": "0.0.0-20230409084303-221991d4-nightly",
|
||||
"@toeverything/infra": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"lit": "^2.8.0"
|
||||
},
|
||||
"version": "0.10.0-canary.1"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { isValidIPAddress } from '../is-valid-ip-address.js';
|
||||
|
||||
describe('isValidIpAddress', () => {
|
||||
test('should return true for valid IP address', () => {
|
||||
['115.42.150.37', '192.168.0.1', '110.234.52.124', 'localhost'].forEach(
|
||||
ip => {
|
||||
expect(isValidIPAddress(ip)).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should return false for invalid IP address', () => {
|
||||
[
|
||||
'210.110',
|
||||
'255',
|
||||
'y.y.y.y',
|
||||
'255.0.0.y',
|
||||
'666.10.10.20',
|
||||
'4444.11.11.11',
|
||||
'33.3333.33.3',
|
||||
].forEach(ip => {
|
||||
expect(isValidIPAddress(ip)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type Action<
|
||||
InputSchema extends z.ZodObject<any, any, any, any>,
|
||||
Args extends readonly any[],
|
||||
> = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: InputSchema;
|
||||
action: (input: z.input<InputSchema>, ...args: Args) => void;
|
||||
};
|
||||
Vendored
+131
@@ -0,0 +1,131 @@
|
||||
// This file should has not side effect
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
appInfo: {
|
||||
electron: boolean;
|
||||
schema: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
//#region runtime variables
|
||||
export const isBrowser = typeof window !== 'undefined';
|
||||
export const isServer = !isBrowser && typeof navigator === 'undefined';
|
||||
export const isDesktop = isBrowser && !!window.appInfo?.electron;
|
||||
//#endregion
|
||||
export const DEFAULT_WORKSPACE_NAME = 'Demo Workspace';
|
||||
export const UNTITLED_WORKSPACE_NAME = 'Untitled';
|
||||
|
||||
export const DEFAULT_SORT_KEY = 'updatedDate';
|
||||
export const MessageCode = {
|
||||
loginError: 0,
|
||||
noPermission: 1,
|
||||
loadListFailed: 2,
|
||||
getDetailFailed: 3,
|
||||
createWorkspaceFailed: 4,
|
||||
getMembersFailed: 5,
|
||||
updateWorkspaceFailed: 6,
|
||||
deleteWorkspaceFailed: 7,
|
||||
inviteMemberFailed: 8,
|
||||
removeMemberFailed: 9,
|
||||
acceptInvitingFailed: 10,
|
||||
getBlobFailed: 11,
|
||||
leaveWorkspaceFailed: 12,
|
||||
downloadWorkspaceFailed: 13,
|
||||
refreshTokenError: 14,
|
||||
blobTooLarge: 15,
|
||||
} as const;
|
||||
|
||||
export const Messages = {
|
||||
[MessageCode.loginError]: {
|
||||
message: 'Login failed',
|
||||
},
|
||||
[MessageCode.noPermission]: {
|
||||
message: 'No permission',
|
||||
},
|
||||
[MessageCode.loadListFailed]: {
|
||||
message: 'Load list failed',
|
||||
},
|
||||
[MessageCode.getDetailFailed]: {
|
||||
message: 'Get detail failed',
|
||||
},
|
||||
[MessageCode.createWorkspaceFailed]: {
|
||||
message: 'Create workspace failed',
|
||||
},
|
||||
[MessageCode.getMembersFailed]: {
|
||||
message: 'Get members failed',
|
||||
},
|
||||
[MessageCode.updateWorkspaceFailed]: {
|
||||
message: 'Update workspace failed',
|
||||
},
|
||||
[MessageCode.deleteWorkspaceFailed]: {
|
||||
message: 'Delete workspace failed',
|
||||
},
|
||||
[MessageCode.inviteMemberFailed]: {
|
||||
message: 'Invite member failed',
|
||||
},
|
||||
[MessageCode.removeMemberFailed]: {
|
||||
message: 'Remove member failed',
|
||||
},
|
||||
[MessageCode.acceptInvitingFailed]: {
|
||||
message: 'Accept inviting failed',
|
||||
},
|
||||
[MessageCode.getBlobFailed]: {
|
||||
message: 'Get blob failed',
|
||||
},
|
||||
[MessageCode.leaveWorkspaceFailed]: {
|
||||
message: 'Leave workspace failed',
|
||||
},
|
||||
[MessageCode.downloadWorkspaceFailed]: {
|
||||
message: 'Download workspace failed',
|
||||
},
|
||||
[MessageCode.refreshTokenError]: {
|
||||
message: 'Refresh token failed',
|
||||
},
|
||||
[MessageCode.blobTooLarge]: {
|
||||
message: 'Blob too large',
|
||||
},
|
||||
} as const satisfies {
|
||||
[key in (typeof MessageCode)[keyof typeof MessageCode]]: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class PageNotFoundError extends TypeError {
|
||||
readonly workspace: Workspace;
|
||||
readonly pageId: string;
|
||||
|
||||
constructor(workspace: Workspace, pageId: string) {
|
||||
super();
|
||||
this.workspace = workspace;
|
||||
this.pageId = pageId;
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceNotFoundError extends TypeError {
|
||||
readonly workspaceId: string;
|
||||
|
||||
constructor(workspaceId: string) {
|
||||
super();
|
||||
this.workspaceId = workspaceId;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryParamError extends TypeError {
|
||||
readonly targetKey: string;
|
||||
readonly query: unknown;
|
||||
|
||||
constructor(targetKey: string, query: unknown) {
|
||||
super();
|
||||
this.targetKey = targetKey;
|
||||
this.query = query;
|
||||
}
|
||||
}
|
||||
|
||||
export class Unreachable extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const literalValueSchema: z.ZodType<LiteralValue, z.ZodTypeDef> =
|
||||
z.union([
|
||||
z.number(),
|
||||
z.string(),
|
||||
z.boolean(),
|
||||
z.array(z.lazy(() => literalValueSchema)),
|
||||
z.record(z.lazy(() => literalValueSchema)),
|
||||
]);
|
||||
|
||||
export type LiteralValue =
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| { [K: string]: LiteralValue }
|
||||
| Array<LiteralValue>;
|
||||
|
||||
export const refSchema: z.ZodType<Ref, z.ZodTypeDef> = z.object({
|
||||
type: z.literal('ref'),
|
||||
name: z.never(),
|
||||
});
|
||||
|
||||
export type Ref = {
|
||||
type: 'ref';
|
||||
name: keyof VariableMap;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface VariableMap {}
|
||||
|
||||
export const literalSchema = z.object({
|
||||
type: z.literal('literal'),
|
||||
value: literalValueSchema,
|
||||
});
|
||||
|
||||
export type Literal = z.input<typeof literalSchema>;
|
||||
|
||||
export const filterSchema = z.object({
|
||||
type: z.literal('filter'),
|
||||
left: refSchema,
|
||||
funcName: z.string(),
|
||||
args: z.array(literalSchema),
|
||||
});
|
||||
|
||||
export type Filter = z.input<typeof filterSchema>;
|
||||
|
||||
export const collectionSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
name: z.string(),
|
||||
pinned: z.boolean().optional(),
|
||||
filterList: z.array(filterSchema),
|
||||
allowList: z.array(z.string()).optional(),
|
||||
excludeList: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type Collection = z.input<typeof collectionSchema>;
|
||||
|
||||
export const tagSchema = z.object({
|
||||
id: z.string(),
|
||||
value: z.string(),
|
||||
color: z.string(),
|
||||
parentId: z.string().optional(),
|
||||
});
|
||||
export type Tag = z.input<typeof tagSchema>;
|
||||
|
||||
export type PropertiesMeta = Workspace['meta']['properties'];
|
||||
Vendored
+161
@@ -0,0 +1,161 @@
|
||||
/// <reference types="@blocksuite/global" />
|
||||
import { assertEquals } from '@blocksuite/global/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isDesktop, isServer } from './constant.js';
|
||||
import { UaHelper } from './ua-helper.js';
|
||||
|
||||
export const blockSuiteFeatureFlags = z.object({
|
||||
enable_set_remote_flag: z.boolean(),
|
||||
enable_block_hub: z.boolean(),
|
||||
|
||||
enable_toggle_block: z.boolean(),
|
||||
enable_bookmark_operation: z.boolean(),
|
||||
enable_note_index: z.boolean(),
|
||||
});
|
||||
|
||||
export const runtimeFlagsSchema = z.object({
|
||||
enablePlugin: z.boolean(),
|
||||
enableTestProperties: z.boolean(),
|
||||
enableBroadcastChannelProvider: z.boolean(),
|
||||
enableDebugPage: z.boolean(),
|
||||
changelogUrl: z.string(),
|
||||
// see: tools/workers
|
||||
imageProxyUrl: z.string(),
|
||||
enablePreloading: z.boolean(),
|
||||
enableNewSettingModal: z.boolean(),
|
||||
enableNewSettingUnstableApi: z.boolean(),
|
||||
enableSQLiteProvider: z.boolean(),
|
||||
enableNotificationCenter: z.boolean(),
|
||||
enableCloud: z.boolean(),
|
||||
enableCaptcha: z.boolean(),
|
||||
enableEnhanceShareMode: z.boolean(),
|
||||
// this is for the electron app
|
||||
serverUrlPrefix: z.string(),
|
||||
enableMoveDatabase: z.boolean(),
|
||||
editorFlags: blockSuiteFeatureFlags,
|
||||
appVersion: z.string(),
|
||||
editorVersion: z.string(),
|
||||
});
|
||||
|
||||
export type BlockSuiteFeatureFlags = z.infer<typeof blockSuiteFeatureFlags>;
|
||||
|
||||
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;
|
||||
|
||||
export const platformSchema = z.enum([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd',
|
||||
]);
|
||||
|
||||
export type Platform = z.infer<typeof platformSchema>;
|
||||
|
||||
type BrowserBase = {
|
||||
/**
|
||||
* @example https://app.affine.pro
|
||||
* @example http://localhost:3000
|
||||
*/
|
||||
origin: string;
|
||||
isDesktop: boolean;
|
||||
isBrowser: true;
|
||||
isServer: false;
|
||||
isDebug: boolean;
|
||||
|
||||
// browser special properties
|
||||
isLinux: boolean;
|
||||
isMacOs: boolean;
|
||||
isIOS: boolean;
|
||||
isSafari: boolean;
|
||||
isWindows: boolean;
|
||||
isFireFox: boolean;
|
||||
isMobile: boolean;
|
||||
isChrome: boolean;
|
||||
};
|
||||
|
||||
type NonChromeBrowser = BrowserBase & {
|
||||
isChrome: false;
|
||||
};
|
||||
|
||||
type ChromeBrowser = BrowserBase & {
|
||||
isSafari: false;
|
||||
isFireFox: false;
|
||||
isChrome: true;
|
||||
chromeVersion: number;
|
||||
};
|
||||
|
||||
type Browser = NonChromeBrowser | ChromeBrowser;
|
||||
|
||||
type Server = {
|
||||
isDesktop: false;
|
||||
isBrowser: false;
|
||||
isServer: true;
|
||||
isDebug: boolean;
|
||||
};
|
||||
|
||||
interface Desktop extends ChromeBrowser {
|
||||
isDesktop: true;
|
||||
isBrowser: true;
|
||||
isServer: false;
|
||||
isDebug: boolean;
|
||||
}
|
||||
|
||||
export type Environment = Browser | Server | Desktop;
|
||||
|
||||
export function setupGlobal() {
|
||||
if (globalThis.$AFFINE_SETUP) {
|
||||
return;
|
||||
}
|
||||
runtimeFlagsSchema.parse(runtimeConfig);
|
||||
|
||||
let environment: Environment;
|
||||
const isDebug = process.env.NODE_ENV === 'development';
|
||||
if (isServer) {
|
||||
environment = {
|
||||
isDesktop: false,
|
||||
isBrowser: false,
|
||||
isServer: true,
|
||||
isDebug,
|
||||
} satisfies Server;
|
||||
} else {
|
||||
const uaHelper = new UaHelper(navigator);
|
||||
|
||||
environment = {
|
||||
origin: window.location.origin,
|
||||
isDesktop,
|
||||
isBrowser: true,
|
||||
isServer: false,
|
||||
isDebug,
|
||||
isLinux: uaHelper.isLinux,
|
||||
isMacOs: uaHelper.isMacOs,
|
||||
isSafari: uaHelper.isSafari,
|
||||
isWindows: uaHelper.isWindows,
|
||||
isFireFox: uaHelper.isFireFox,
|
||||
isMobile: uaHelper.isMobile,
|
||||
isChrome: uaHelper.isChrome,
|
||||
isIOS: uaHelper.isIOS,
|
||||
} as Browser;
|
||||
// Chrome on iOS is still Safari
|
||||
if (environment.isChrome && !environment.isIOS) {
|
||||
assertEquals(environment.isSafari, false);
|
||||
assertEquals(environment.isFireFox, false);
|
||||
environment = {
|
||||
...environment,
|
||||
isSafari: false,
|
||||
isFireFox: false,
|
||||
isChrome: true,
|
||||
chromeVersion: uaHelper.getChromeVersion(),
|
||||
} satisfies ChromeBrowser;
|
||||
}
|
||||
}
|
||||
globalThis.environment = environment;
|
||||
|
||||
globalThis.$AFFINE_SETUP = true;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function isValidIPAddress(address: string) {
|
||||
if (address === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
|
||||
address
|
||||
);
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export type PageInfo = {
|
||||
isEdgeless: boolean;
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GetPageInfoById = (id: string) => PageInfo | undefined;
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
export class UaHelper {
|
||||
private uaMap;
|
||||
public isLinux = false;
|
||||
public isMacOs = false;
|
||||
public isSafari = false;
|
||||
public isWindows = false;
|
||||
public isFireFox = false;
|
||||
public isMobile = false;
|
||||
public isChrome = false;
|
||||
public isIOS = false;
|
||||
|
||||
getChromeVersion = (): number => {
|
||||
const raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
assertExists(raw);
|
||||
return parseInt(raw[2], 10);
|
||||
};
|
||||
|
||||
constructor(private readonly navigator: Navigator) {
|
||||
this.uaMap = getUa(navigator);
|
||||
this.initUaFlags();
|
||||
}
|
||||
|
||||
public checkUseragent(isUseragent: keyof ReturnType<typeof getUa>) {
|
||||
return Boolean(this.uaMap[isUseragent]);
|
||||
}
|
||||
|
||||
private initUaFlags() {
|
||||
this.isLinux = this.checkUseragent('linux');
|
||||
this.isMacOs = this.checkUseragent('mac');
|
||||
this.isSafari = this.checkUseragent('safari');
|
||||
this.isWindows = this.checkUseragent('win');
|
||||
this.isFireFox = this.checkUseragent('firefox');
|
||||
this.isMobile = this.checkUseragent('mobile');
|
||||
this.isChrome = this.checkUseragent('chrome');
|
||||
this.isIOS = this.checkUseragent('ios');
|
||||
}
|
||||
}
|
||||
|
||||
const getUa = (navigator: Navigator) => {
|
||||
const ua = navigator.userAgent;
|
||||
const uas = ua.toLowerCase();
|
||||
const mobile = /iPhone|iPad|iPod|Android/i.test(ua);
|
||||
const android =
|
||||
(mobile && (uas.indexOf('android') > -1 || uas.indexOf('linux') > -1)) ||
|
||||
uas.indexOf('adr') > -1;
|
||||
const ios = mobile && !android && /Mac OS/i.test(ua);
|
||||
const mac = !mobile && /Mac OS/i.test(ua);
|
||||
const iphone = ios && uas.indexOf('iphone') > -1;
|
||||
const ipad = ios && !iphone;
|
||||
const wx = /MicroMessenger/i.test(ua);
|
||||
const chrome = /CriOS/i.test(ua) || /Chrome/i.test(ua);
|
||||
const tiktok = mobile && /aweme/i.test(ua);
|
||||
const weibo = mobile && /Weibo/i.test(ua);
|
||||
const safari =
|
||||
ios && !chrome && !wx && !weibo && !tiktok && /Safari|Macintosh/i.test(ua);
|
||||
const firefox = /Firefox/.test(ua);
|
||||
const win = /windows|win32|win64|wow32|wow64/.test(uas);
|
||||
const linux = /linux/.test(uas);
|
||||
return {
|
||||
ua,
|
||||
mobile,
|
||||
android,
|
||||
ios,
|
||||
mac,
|
||||
wx,
|
||||
chrome,
|
||||
iphone,
|
||||
ipad,
|
||||
safari,
|
||||
tiktok,
|
||||
weibo,
|
||||
win,
|
||||
linux,
|
||||
firefox,
|
||||
};
|
||||
};
|
||||
Vendored
+208
@@ -0,0 +1,208 @@
|
||||
import type { EditorContainer } from '@blocksuite/editor';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import type {
|
||||
ActiveDocProvider,
|
||||
PassiveDocProvider,
|
||||
Workspace as BlockSuiteWorkspace,
|
||||
} from '@blocksuite/store';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import type { DataSourceAdapter } from 'y-provider';
|
||||
|
||||
import type { Collection } from './filter.js';
|
||||
|
||||
export enum WorkspaceSubPath {
|
||||
ALL = 'all',
|
||||
SETTING = 'setting',
|
||||
TRASH = 'trash',
|
||||
SHARED = 'shared',
|
||||
}
|
||||
|
||||
export interface AffineDownloadProvider extends PassiveDocProvider {
|
||||
flavour: 'affine-download';
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the first binary from local IndexedDB
|
||||
*/
|
||||
export interface BroadCastChannelProvider extends PassiveDocProvider {
|
||||
flavour: 'broadcast-channel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Long polling provider with local IndexedDB
|
||||
*/
|
||||
export interface LocalIndexedDBBackgroundProvider
|
||||
extends DataSourceAdapter,
|
||||
PassiveDocProvider {
|
||||
flavour: 'local-indexeddb-background';
|
||||
}
|
||||
|
||||
export interface LocalIndexedDBDownloadProvider extends ActiveDocProvider {
|
||||
flavour: 'local-indexeddb';
|
||||
}
|
||||
|
||||
export interface SQLiteProvider extends PassiveDocProvider, DataSourceAdapter {
|
||||
flavour: 'sqlite';
|
||||
}
|
||||
|
||||
export interface SQLiteDBDownloadProvider extends ActiveDocProvider {
|
||||
flavour: 'sqlite-download';
|
||||
}
|
||||
|
||||
export interface AffineSocketIOProvider
|
||||
extends PassiveDocProvider,
|
||||
DataSourceAdapter {
|
||||
flavour: 'affine-socket-io';
|
||||
}
|
||||
|
||||
type BaseWorkspace = {
|
||||
flavour: string;
|
||||
id: string;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
};
|
||||
|
||||
export interface AffineCloudWorkspace extends BaseWorkspace {
|
||||
flavour: WorkspaceFlavour.AFFINE_CLOUD;
|
||||
id: string;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
}
|
||||
|
||||
export interface LocalWorkspace extends BaseWorkspace {
|
||||
flavour: WorkspaceFlavour.LOCAL;
|
||||
id: string;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
}
|
||||
|
||||
export interface AffinePublicWorkspace extends BaseWorkspace {
|
||||
flavour: WorkspaceFlavour.AFFINE_PUBLIC;
|
||||
id: string;
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
}
|
||||
|
||||
export type AffineOfficialWorkspace =
|
||||
| AffineCloudWorkspace
|
||||
| LocalWorkspace
|
||||
| AffinePublicWorkspace;
|
||||
|
||||
export enum ReleaseType {
|
||||
// if workspace is not released yet, we will not show it in the workspace list
|
||||
UNRELEASED = 'unreleased',
|
||||
STABLE = 'stable',
|
||||
}
|
||||
|
||||
export enum LoadPriority {
|
||||
HIGH = 1,
|
||||
MEDIUM = 2,
|
||||
LOW = 3,
|
||||
}
|
||||
|
||||
export enum WorkspaceFlavour {
|
||||
/**
|
||||
* New AFFiNE Cloud Workspace using Nest.js Server.
|
||||
*/
|
||||
AFFINE_CLOUD = 'affine-cloud',
|
||||
LOCAL = 'local',
|
||||
AFFINE_PUBLIC = 'affine-public',
|
||||
}
|
||||
|
||||
export const settingPanel = {
|
||||
General: 'general',
|
||||
Collaboration: 'collaboration',
|
||||
Publish: 'publish',
|
||||
Export: 'export',
|
||||
Sync: 'sync',
|
||||
} as const;
|
||||
export const settingPanelValues = [...Object.values(settingPanel)] as const;
|
||||
export type SettingPanel = (typeof settingPanel)[keyof typeof settingPanel];
|
||||
|
||||
// built-in workspaces
|
||||
export interface WorkspaceRegistry {
|
||||
[WorkspaceFlavour.LOCAL]: LocalWorkspace;
|
||||
[WorkspaceFlavour.AFFINE_PUBLIC]: AffinePublicWorkspace;
|
||||
[WorkspaceFlavour.AFFINE_CLOUD]: AffineCloudWorkspace;
|
||||
}
|
||||
|
||||
export interface WorkspaceCRUD<Flavour extends keyof WorkspaceRegistry> {
|
||||
create: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise<string>;
|
||||
delete: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise<void>;
|
||||
get: (workspaceId: string) => Promise<WorkspaceRegistry[Flavour] | null>;
|
||||
// not supported yet
|
||||
// update: (workspace: FlavourToWorkspace[Flavour]) => Promise<void>;
|
||||
list: () => Promise<WorkspaceRegistry[Flavour][]>;
|
||||
}
|
||||
|
||||
type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = {
|
||||
currentWorkspaceId: string;
|
||||
};
|
||||
|
||||
export type WorkspaceHeaderProps<Flavour extends keyof WorkspaceRegistry> =
|
||||
UIBaseProps<Flavour> & {
|
||||
currentEntry:
|
||||
| {
|
||||
subPath: WorkspaceSubPath;
|
||||
}
|
||||
| {
|
||||
pageId: string;
|
||||
};
|
||||
};
|
||||
|
||||
type NewSettingProps<Flavour extends keyof WorkspaceRegistry> =
|
||||
UIBaseProps<Flavour> & {
|
||||
onDeleteLocalWorkspace: () => void;
|
||||
onDeleteCloudWorkspace: () => void;
|
||||
onLeaveWorkspace: () => void;
|
||||
onTransformWorkspace: <
|
||||
From extends keyof WorkspaceRegistry,
|
||||
To extends keyof WorkspaceRegistry,
|
||||
>(
|
||||
from: From,
|
||||
to: To,
|
||||
workspace: WorkspaceRegistry[From]
|
||||
) => void;
|
||||
};
|
||||
|
||||
type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
|
||||
UIBaseProps<Flavour> & {
|
||||
currentPageId: string;
|
||||
onLoadEditor: (page: Page, editor: EditorContainer) => () => void;
|
||||
};
|
||||
|
||||
type PageListProps<_Flavour extends keyof WorkspaceRegistry> = {
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
interface FC<P> {
|
||||
(props: P): ReactNode;
|
||||
}
|
||||
|
||||
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
|
||||
Header: FC<WorkspaceHeaderProps<Flavour>>;
|
||||
PageDetail: FC<PageDetailProps<Flavour>>;
|
||||
PageList: FC<PageListProps<Flavour>>;
|
||||
NewSettingsDetail: FC<NewSettingProps<Flavour>>;
|
||||
Provider: FC<PropsWithChildren>;
|
||||
LoginCard?: FC<object>;
|
||||
}
|
||||
|
||||
export interface AppEvents {
|
||||
// event there is no workspace
|
||||
// usually used to initialize workspace adapter
|
||||
'app:init': () => string[];
|
||||
// event if you have access to workspace adapter
|
||||
'app:access': () => Promise<boolean>;
|
||||
'service:start': () => void;
|
||||
'service:stop': () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceAdapter<Flavour extends WorkspaceFlavour> {
|
||||
releaseType: ReleaseType;
|
||||
flavour: Flavour;
|
||||
// The Adapter will be loaded according to the priority
|
||||
loadPriority: LoadPriority;
|
||||
Events: Partial<AppEvents>;
|
||||
// Fetch necessary data for the first render
|
||||
CRUD: WorkspaceCRUD<Flavour>;
|
||||
UI: WorkspaceUISchema<Flavour>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @deprecated Remove this file after we migrate to the new cloud.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
create_at: string;
|
||||
}
|
||||
|
||||
export interface GetUserByEmailParams {
|
||||
email: string;
|
||||
workspace_id: string;
|
||||
}
|
||||
|
||||
export const usageResponseSchema = z.object({
|
||||
blob_usage: z.object({
|
||||
usage: z.number(),
|
||||
max_usage: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UsageResponse = z.infer<typeof usageResponseSchema>;
|
||||
|
||||
export interface GetWorkspaceDetailParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export enum WorkspaceType {
|
||||
Private = 0,
|
||||
Normal = 1,
|
||||
}
|
||||
|
||||
export enum PermissionType {
|
||||
Read = 0,
|
||||
Write = 1,
|
||||
Admin = 10,
|
||||
Owner = 99,
|
||||
}
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
avatar_url: z.string(),
|
||||
created_at: z.number(),
|
||||
});
|
||||
|
||||
export const workspaceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.nativeEnum(WorkspaceType),
|
||||
public: z.boolean(),
|
||||
permission: z.nativeEnum(PermissionType),
|
||||
});
|
||||
|
||||
export type Workspace = z.infer<typeof workspaceSchema>;
|
||||
|
||||
export const workspaceDetailSchema = z.object({
|
||||
...workspaceSchema.shape,
|
||||
permission: z.undefined(),
|
||||
owner: userSchema,
|
||||
member_count: z.number(),
|
||||
});
|
||||
|
||||
export type WorkspaceDetail = z.infer<typeof workspaceDetailSchema>;
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
type: PermissionType;
|
||||
workspace_id: string;
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
accepted: boolean;
|
||||
create_at: number;
|
||||
}
|
||||
|
||||
export interface RegisteredUser extends User {
|
||||
type: 'Registered';
|
||||
}
|
||||
|
||||
export interface UnregisteredUser {
|
||||
type: 'Unregistered';
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface Member extends Permission {
|
||||
user: RegisteredUser | UnregisteredUser;
|
||||
}
|
||||
|
||||
export interface GetWorkspaceMembersParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceParams {
|
||||
id: string;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteWorkspaceParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface InviteMemberParams {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface RemoveMemberParams {
|
||||
permissionId: number;
|
||||
}
|
||||
|
||||
export interface AcceptInvitingParams {
|
||||
invitingCode: string;
|
||||
}
|
||||
|
||||
export interface LeaveWorkspaceParams {
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
export const createWorkspaceResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
public: z.boolean(),
|
||||
type: z.nativeEnum(WorkspaceType),
|
||||
created_at: z.number(),
|
||||
});
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../infra"
|
||||
},
|
||||
{
|
||||
"path": "../../../tests/fixtures"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": "@toeverything/infra",
|
||||
"type": "module",
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.cjs",
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./blocksuite": {
|
||||
"types": "./dist/src/blocksuite/index.d.ts",
|
||||
"import": "./dist/blocksuite.js",
|
||||
"require": "./dist/blocksuite.cjs"
|
||||
},
|
||||
"./command": {
|
||||
"types": "./dist/src/command/index.d.ts",
|
||||
"import": "./dist/command.js",
|
||||
"require": "./dist/command.cjs"
|
||||
},
|
||||
"./core/*": {
|
||||
"types": "./dist/src/core/*.d.ts",
|
||||
"import": "./dist/core/*.js",
|
||||
"require": "./dist/core/*.cjs"
|
||||
},
|
||||
"./preload/*": {
|
||||
"types": "./dist/src/preload/*.d.ts",
|
||||
"import": "./dist/preload/*.js",
|
||||
"require": "./dist/preload/*.cjs"
|
||||
},
|
||||
"./atom": {
|
||||
"type": "./dist/src/atom.d.ts",
|
||||
"import": "./dist/atom.js",
|
||||
"require": "./dist/atom.cjs"
|
||||
},
|
||||
"./type": {
|
||||
"type": "./dist/src/type.d.ts",
|
||||
"import": "./dist/type.js",
|
||||
"require": "./dist/type.cjs"
|
||||
},
|
||||
"./__internal__/*": {
|
||||
"type": "./dist/src/__internal__/*.d.ts",
|
||||
"import": "./dist/__internal__/*.js",
|
||||
"require": "./dist/__internal__/*.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/sdk": "workspace:*",
|
||||
"@blocksuite/blocks": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"jotai": "^2.4.3",
|
||||
"jotai-effect": "^0.1.0",
|
||||
"tinykeys": "^2.1.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/editor": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"async-call-rpc": "^6.3.1",
|
||||
"electron": "link:../../frontend/electron/node_modules/electron",
|
||||
"nanoid": "^5.0.1",
|
||||
"react": "^18.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "3.6.0",
|
||||
"vitest": "0.34.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@affine/templates": "*",
|
||||
"@blocksuite/editor": "*",
|
||||
"@blocksuite/lit": "*",
|
||||
"async-call-rpc": "*",
|
||||
"electron": "*",
|
||||
"react": "*",
|
||||
"yjs": "^13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@affine/templates": {
|
||||
"optional": true
|
||||
},
|
||||
"@blocksuite/editor": {
|
||||
"optional": true
|
||||
},
|
||||
"@blocksuite/lit": {
|
||||
"optional": true
|
||||
},
|
||||
"async-call-rpc": {
|
||||
"optional": true
|
||||
},
|
||||
"electron": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"yjs": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"version": "0.10.0-canary.1"
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
// @ts-ignore
|
||||
export * from '../dist/src/preload/electron';
|
||||
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
/// <reference types="../dist/preload/electron.d.ts" />
|
||||
export * from '../dist/preload/electron.js';
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "infra",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "packages/common/src",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "nx:run-script",
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["{projectRoot}/**/*"],
|
||||
"options": {
|
||||
"script": "build"
|
||||
},
|
||||
"outputs": ["{projectRoot}/dist"]
|
||||
}
|
||||
},
|
||||
"tags": ["infra"]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CallbackMap } from '@affine/sdk/entry';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
import { atom } from 'jotai/vanilla';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { packageJsonOutputSchema } from '../type.js';
|
||||
|
||||
export const builtinPluginPaths = new Set([
|
||||
'/plugins/copilot',
|
||||
'/plugins/hello-world',
|
||||
'/plugins/image-preview',
|
||||
'/plugins/vue-hello-world',
|
||||
'/plugins/outline',
|
||||
]);
|
||||
|
||||
const pluginCleanupMap = new Map<string, Set<() => void>>();
|
||||
|
||||
export function addCleanup(
|
||||
pluginName: string,
|
||||
cleanup: () => void
|
||||
): () => void {
|
||||
if (!pluginCleanupMap.has(pluginName)) {
|
||||
pluginCleanupMap.set(pluginName, new Set());
|
||||
}
|
||||
const cleanupSet = pluginCleanupMap.get(pluginName);
|
||||
assertExists(cleanupSet);
|
||||
cleanupSet.add(cleanup);
|
||||
return () => {
|
||||
cleanupSet.delete(cleanup);
|
||||
};
|
||||
}
|
||||
|
||||
export function invokeCleanup(pluginName: string) {
|
||||
pluginCleanupMap.get(pluginName)?.forEach(cleanup => cleanup());
|
||||
pluginCleanupMap.delete(pluginName);
|
||||
}
|
||||
|
||||
export const pluginPackageJson = atom<
|
||||
z.infer<typeof packageJsonOutputSchema>[]
|
||||
>([]);
|
||||
|
||||
export const enabledPluginAtom = atomWithStorage('affine-enabled-plugin', [
|
||||
'@affine/image-preview-plugin',
|
||||
'@affine/outline-plugin',
|
||||
]);
|
||||
|
||||
export const pluginHeaderItemAtom = atom<
|
||||
Record<string, CallbackMap['headerItem']>
|
||||
>({});
|
||||
|
||||
export const pluginSettingAtom = atom<Record<string, CallbackMap['setting']>>(
|
||||
{}
|
||||
);
|
||||
|
||||
export const pluginEditorAtom = atom<Record<string, CallbackMap['editor']>>({});
|
||||
|
||||
export const pluginWindowAtom = atom<
|
||||
Record<string, (root: HTMLElement) => () => void>
|
||||
>({});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ActiveDocProvider, Workspace } from '@blocksuite/store';
|
||||
import type { PassiveDocProvider } from '@blocksuite/store';
|
||||
import type { Atom } from 'jotai/vanilla';
|
||||
import { atom } from 'jotai/vanilla';
|
||||
import { atomEffect } from 'jotai-effect';
|
||||
|
||||
/**
|
||||
* Map: guid -> Workspace
|
||||
*/
|
||||
export const INTERNAL_BLOCKSUITE_HASH_MAP = new Map<string, Workspace>([]);
|
||||
|
||||
const workspaceActiveAtomWeakMap = new WeakMap<
|
||||
Workspace,
|
||||
Atom<Promise<Workspace>>
|
||||
>();
|
||||
|
||||
const workspaceActiveWeakMap = new WeakMap<Workspace, boolean>();
|
||||
const workspaceEffectAtomWeakMap = new WeakMap<Workspace, Atom<void>>();
|
||||
|
||||
export async function waitForWorkspace(workspace: Workspace) {
|
||||
if (workspaceActiveWeakMap.get(workspace) !== true) {
|
||||
const providers = workspace.providers.filter(
|
||||
(provider): provider is ActiveDocProvider =>
|
||||
'active' in provider && provider.active === true
|
||||
);
|
||||
for (const provider of providers) {
|
||||
provider.sync();
|
||||
// we will wait for the necessary providers to be ready
|
||||
await provider.whenReady;
|
||||
}
|
||||
// timeout is INFINITE
|
||||
workspaceActiveWeakMap.set(workspace, true);
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspace(id: string) {
|
||||
if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
return INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace;
|
||||
}
|
||||
|
||||
export function getBlockSuiteWorkspaceAtom(
|
||||
id: string
|
||||
): [workspaceAtom: Atom<Promise<Workspace>>, workspaceEffectAtom: Atom<void>] {
|
||||
if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
const workspace = INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace;
|
||||
if (!workspaceActiveAtomWeakMap.has(workspace)) {
|
||||
const baseAtom = atom(async () => {
|
||||
await waitForWorkspace(workspace);
|
||||
return workspace;
|
||||
});
|
||||
workspaceActiveAtomWeakMap.set(workspace, baseAtom);
|
||||
}
|
||||
if (!workspaceEffectAtomWeakMap.has(workspace)) {
|
||||
const effectAtom = atomEffect(() => {
|
||||
const providers = workspace.providers.filter(
|
||||
(provider): provider is PassiveDocProvider =>
|
||||
'passive' in provider && provider.passive === true
|
||||
);
|
||||
providers.forEach(provider => {
|
||||
provider.connect();
|
||||
});
|
||||
return () => {
|
||||
providers.forEach(provider => {
|
||||
provider.disconnect();
|
||||
});
|
||||
};
|
||||
});
|
||||
workspaceEffectAtomWeakMap.set(workspace, effectAtom);
|
||||
}
|
||||
|
||||
return [
|
||||
workspaceActiveAtomWeakMap.get(workspace) as Atom<Promise<Workspace>>,
|
||||
workspaceEffectAtomWeakMap.get(workspace) as Atom<void>,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { getDefaultStore } from 'jotai/vanilla';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getBlockSuiteWorkspaceAtom,
|
||||
INTERNAL_BLOCKSUITE_HASH_MAP,
|
||||
} from '../__internal__/workspace.js';
|
||||
|
||||
test('blocksuite atom', async () => {
|
||||
const sync = vi.fn();
|
||||
let connected = false;
|
||||
const connect = vi.fn(() => (connected = true));
|
||||
const workspace = new Workspace({
|
||||
schema: new Schema(),
|
||||
id: '1',
|
||||
providerCreators: [
|
||||
() => ({
|
||||
flavour: 'fake',
|
||||
active: true,
|
||||
sync,
|
||||
get whenReady(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
() => ({
|
||||
flavour: 'fake-2',
|
||||
passive: true,
|
||||
get connected() {
|
||||
return connected;
|
||||
},
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
INTERNAL_BLOCKSUITE_HASH_MAP.set('1', workspace);
|
||||
|
||||
{
|
||||
const [atom, effectAtom] = getBlockSuiteWorkspaceAtom('1');
|
||||
const store = getDefaultStore();
|
||||
const result = await store.get(atom);
|
||||
expect(result).toBe(workspace);
|
||||
expect(sync).toBeCalledTimes(1);
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
|
||||
store.sub(effectAtom, vi.fn());
|
||||
await waitFor(() => expect(connect).toBeCalledTimes(1));
|
||||
expect(connected).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import type { Array as YArray, Map as YMap } from 'yjs';
|
||||
import { applyUpdate, Doc } from 'yjs';
|
||||
|
||||
import { migrateToSubdoc } from '../blocksuite/index.js';
|
||||
|
||||
const fixturePath = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'workspace.ydoc'
|
||||
);
|
||||
const yDocBuffer = readFileSync(fixturePath);
|
||||
const doc = new Doc();
|
||||
applyUpdate(doc, new Uint8Array(yDocBuffer));
|
||||
const migratedDoc = migrateToSubdoc(doc);
|
||||
|
||||
describe('migration', () => {
|
||||
test('migration to subdoc', async () => {
|
||||
const { default: json } = await import('@affine-test/fixtures/output.json');
|
||||
const length = Object.keys(json).length;
|
||||
const binary = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
binary[i] = (json as any)[i];
|
||||
}
|
||||
const doc = new Doc();
|
||||
applyUpdate(doc, binary);
|
||||
{
|
||||
// invoke data
|
||||
doc.getMap('space:hello-world');
|
||||
doc.getMap('space:meta');
|
||||
}
|
||||
const blocks = doc.getMap('space:hello-world').toJSON();
|
||||
const newDoc = migrateToSubdoc(doc);
|
||||
const subDoc = newDoc.getMap('spaces').values().next().value as Doc;
|
||||
const data = (subDoc.toJSON() as any).blocks;
|
||||
Object.keys(data).forEach(id => {
|
||||
if (id === 'xyWNqindHH') {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
blocks[id]['sys:flavour'] === 'affine:surface' &&
|
||||
!blocks[id]['prop:elements']
|
||||
) {
|
||||
blocks[id]['prop:elements'] = data[id]['prop:elements'];
|
||||
}
|
||||
expect(data[id]).toEqual(blocks[id]);
|
||||
});
|
||||
});
|
||||
|
||||
test('test fixture should be set correctly', () => {
|
||||
const meta = doc.getMap('space:meta');
|
||||
const versions = meta.get('versions') as YMap<unknown>;
|
||||
expect(versions.get('affine:code')).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
test('metadata should be migrated correctly', () => {
|
||||
const originalMeta = doc.getMap('space:meta');
|
||||
const originalVersions = originalMeta.get('versions') as YMap<unknown>;
|
||||
|
||||
const meta = migratedDoc.getMap('meta');
|
||||
const blockVersions = meta.get('blockVersions') as YMap<unknown>;
|
||||
|
||||
expect(meta.get('workspaceVersion')).toBe(1);
|
||||
expect(blockVersions.get('affine:code')).toBe(
|
||||
originalVersions.get('affine:code')
|
||||
);
|
||||
expect((meta.get('pages') as YArray<unknown>).length).toBe(
|
||||
(originalMeta.get('pages') as YArray<unknown>).length
|
||||
);
|
||||
|
||||
expect(blockVersions.get('affine:embed')).toBeUndefined();
|
||||
expect(blockVersions.get('affine:image')).toBe(
|
||||
originalVersions.get('affine:embed')
|
||||
);
|
||||
|
||||
expect(blockVersions.get('affine:frame')).toBeUndefined();
|
||||
expect(blockVersions.get('affine:note')).toBe(
|
||||
originalVersions.get('affine:frame')
|
||||
);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
import type { ExpectedLayout } from '@affine/sdk/entry';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { atom, createStore } from 'jotai/vanilla';
|
||||
|
||||
import { getBlockSuiteWorkspaceAtom } from './__internal__/workspace';
|
||||
|
||||
// global store
|
||||
let rootStore = createStore();
|
||||
|
||||
export function getCurrentStore() {
|
||||
return rootStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal do not use this function unless you know what you are doing
|
||||
*/
|
||||
export function _setCurrentStore(store: ReturnType<typeof createStore>) {
|
||||
rootStore = store;
|
||||
}
|
||||
|
||||
export const loadedPluginNameAtom = atom<string[]>([]);
|
||||
|
||||
export const currentWorkspaceIdAtom = atom<string | null>(null);
|
||||
export const currentPageIdAtom = atom<string | null>(null);
|
||||
export const currentWorkspaceAtom = atom<Promise<Workspace>>(async get => {
|
||||
const workspaceId = get(currentWorkspaceIdAtom);
|
||||
assertExists(workspaceId);
|
||||
const [currentWorkspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
|
||||
return get(currentWorkspaceAtom);
|
||||
});
|
||||
|
||||
const contentLayoutBaseAtom = atom<ExpectedLayout>('editor');
|
||||
|
||||
type SetStateAction<Value> = Value | ((prev: Value) => Value);
|
||||
export const contentLayoutAtom = atom<
|
||||
ExpectedLayout,
|
||||
[SetStateAction<ExpectedLayout>],
|
||||
void
|
||||
>(
|
||||
get => get(contentLayoutBaseAtom),
|
||||
(_, set, layout) => {
|
||||
set(contentLayoutBaseAtom, prev => {
|
||||
let setV: (prev: ExpectedLayout) => ExpectedLayout;
|
||||
if (typeof layout !== 'function') {
|
||||
setV = () => layout;
|
||||
} else {
|
||||
setV = layout;
|
||||
}
|
||||
const nextValue = setV(prev);
|
||||
if (nextValue === 'editor') {
|
||||
return nextValue;
|
||||
}
|
||||
if (nextValue.first !== 'editor') {
|
||||
throw new Error('The first element of the layout should be editor.');
|
||||
}
|
||||
if (nextValue.splitPercentage && nextValue.splitPercentage < 70) {
|
||||
throw new Error('The split percentage should be greater than 70.');
|
||||
}
|
||||
return nextValue;
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,704 @@
|
||||
import type { Page, PageMeta, Workspace } from '@blocksuite/store';
|
||||
import { createIndexeddbStorage } from '@blocksuite/store';
|
||||
import type { createStore, WritableAtom } from 'jotai/vanilla';
|
||||
import type { Doc } from 'yjs';
|
||||
import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
|
||||
|
||||
export async function initEmptyPage(page: Page, title?: string) {
|
||||
await page.waitForLoaded();
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
title: new page.Text(title ?? ''),
|
||||
});
|
||||
page.addBlock('affine:surface', {}, pageBlockId);
|
||||
const noteBlockId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, noteBlockId);
|
||||
}
|
||||
|
||||
export async function buildEmptyBlockSuite(workspace: Workspace) {
|
||||
const page = workspace.createPage();
|
||||
await initEmptyPage(page);
|
||||
workspace.setPageMeta(page.id, {
|
||||
jumpOnce: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildShowcaseWorkspace(
|
||||
workspace: Workspace,
|
||||
options: {
|
||||
schema: Schema;
|
||||
atoms: {
|
||||
pageMode: WritableAtom<
|
||||
undefined,
|
||||
[pageId: string, mode: 'page' | 'edgeless'],
|
||||
void
|
||||
>;
|
||||
};
|
||||
store: ReturnType<typeof createStore>;
|
||||
}
|
||||
) {
|
||||
const prototypes = {
|
||||
tags: {
|
||||
options: [
|
||||
{
|
||||
id: 'icg1n5UdkP',
|
||||
value: 'Travel',
|
||||
color: 'var(--affine-tag-gray)',
|
||||
},
|
||||
{
|
||||
id: 'Oe5dSe1DDJ',
|
||||
value: 'Quick summary',
|
||||
color: 'var(--affine-tag-green)',
|
||||
},
|
||||
{
|
||||
id: 'g1L5dXKctL',
|
||||
value: 'OKR',
|
||||
color: 'var(--affine-tag-purple)',
|
||||
},
|
||||
{
|
||||
id: 'q3mceOl_zi',
|
||||
value: 'Streamline your workflow',
|
||||
color: 'var(--affine-tag-teal)',
|
||||
},
|
||||
{
|
||||
id: 'ze07JVwBu4',
|
||||
value: 'Plan',
|
||||
color: 'var(--affine-tag-teal)',
|
||||
},
|
||||
{
|
||||
id: '8qcYPCTK0h',
|
||||
value: 'Review',
|
||||
color: 'var(--affine-tag-orange)',
|
||||
},
|
||||
{
|
||||
id: 'wg-fBtd2eI',
|
||||
value: 'Engage',
|
||||
color: 'var(--affine-tag-pink)',
|
||||
},
|
||||
{
|
||||
id: 'QYFD_HeQc-',
|
||||
value: 'Create',
|
||||
color: 'var(--affine-tag-blue)',
|
||||
},
|
||||
{
|
||||
id: 'ZHBa2NtdSo',
|
||||
value: 'Learn',
|
||||
color: 'var(--affine-tag-yellow)',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
workspace.meta.setProperties(prototypes);
|
||||
const edgelessPage1 = nanoid();
|
||||
const edgelessPage2 = nanoid();
|
||||
const edgelessPage3 = nanoid();
|
||||
const { store, atoms } = options;
|
||||
[edgelessPage1, edgelessPage2, edgelessPage3].forEach(pageId => {
|
||||
store.set(atoms.pageMode, pageId, 'edgeless');
|
||||
});
|
||||
|
||||
const pageMetas = {
|
||||
'9f6f3c04-cf32-470c-9648-479dc838f10e': {
|
||||
createDate: 1691548231530,
|
||||
tags: ['ZHBa2NtdSo', 'QYFD_HeQc-', 'wg-fBtd2eI'],
|
||||
updatedDate: 1691676331623,
|
||||
favorite: true,
|
||||
jumpOnce: true,
|
||||
},
|
||||
'0773e198-5de0-45d4-a35e-de22ea72b96b': {
|
||||
createDate: 1691548220794,
|
||||
tags: [],
|
||||
updatedDate: 1691676775642,
|
||||
favorite: false,
|
||||
},
|
||||
'59b140eb-4449-488f-9eeb-42412dcc044e': {
|
||||
createDate: 1691551731225,
|
||||
tags: [],
|
||||
updatedDate: 1691654611175,
|
||||
favorite: false,
|
||||
},
|
||||
'7217fbe2-61db-4a91-93c6-ad5c800e5a43': {
|
||||
createDate: 1691552082822,
|
||||
tags: [],
|
||||
updatedDate: 1691654606912,
|
||||
favorite: false,
|
||||
},
|
||||
'6eb43ea8-8c11-456d-bb1d-5193937961ab': {
|
||||
createDate: 1691552090989,
|
||||
tags: [],
|
||||
updatedDate: 1691646748171,
|
||||
favorite: false,
|
||||
},
|
||||
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a': {
|
||||
createDate: 1691564303138,
|
||||
tags: [],
|
||||
updatedDate: 1691646845195,
|
||||
},
|
||||
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238': {
|
||||
createDate: 1691574743531,
|
||||
tags: ['icg1n5UdkP'],
|
||||
updatedDate: 1691647117761,
|
||||
},
|
||||
'22163830-8252-43fe-b62d-fd9bbeaa4caa': {
|
||||
createDate: 1691574859042,
|
||||
tags: [],
|
||||
updatedDate: 1691648159371,
|
||||
},
|
||||
'b7a9e1bc-e205-44aa-8dad-7e328269d00b': {
|
||||
createDate: 1691575011078,
|
||||
tags: ['8qcYPCTK0h'],
|
||||
updatedDate: 1691645074511,
|
||||
favorite: false,
|
||||
},
|
||||
'646305d9-93e0-48df-bb92-d82944ceb5a3': {
|
||||
createDate: 1691634722239,
|
||||
tags: ['ze07JVwBu4'],
|
||||
updatedDate: 1691647069662,
|
||||
favorite: false,
|
||||
},
|
||||
'0350509d-8702-4797-b4d7-168f5e9359c7': {
|
||||
createDate: 1691635388447,
|
||||
tags: ['Oe5dSe1DDJ'],
|
||||
updatedDate: 1691645873930,
|
||||
},
|
||||
'aa02af3c-5c5c-4856-b7ce-947ad17331f3': {
|
||||
createDate: 1691636192263,
|
||||
tags: ['q3mceOl_zi', 'g1L5dXKctL'],
|
||||
updatedDate: 1691645102104,
|
||||
},
|
||||
'9d6e716e-a071-45a2-88ac-2f2f6eec0109': {
|
||||
createDate: 1691574743531,
|
||||
tags: ['icg1n5UdkP'],
|
||||
updatedDate: 1691574743531,
|
||||
},
|
||||
} satisfies Record<string, Partial<PageMeta>>;
|
||||
const data = [
|
||||
[
|
||||
'9f6f3c04-cf32-470c-9648-479dc838f10e',
|
||||
import('@affine/templates/v1/getting-started.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'0773e198-5de0-45d4-a35e-de22ea72b96b',
|
||||
import('@affine/templates/v1/preloading.json'),
|
||||
edgelessPage1,
|
||||
],
|
||||
[
|
||||
'59b140eb-4449-488f-9eeb-42412dcc044e',
|
||||
import('@affine/templates/v1/template-galleries.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'7217fbe2-61db-4a91-93c6-ad5c800e5a43',
|
||||
import('@affine/templates/v1/personal-home.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'6eb43ea8-8c11-456d-bb1d-5193937961ab',
|
||||
import('@affine/templates/v1/working-home.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a',
|
||||
import('@affine/templates/v1/personal-project-management.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238',
|
||||
import('@affine/templates/v1/travel-plan.json'),
|
||||
edgelessPage2,
|
||||
],
|
||||
[
|
||||
'22163830-8252-43fe-b62d-fd9bbeaa4caa',
|
||||
import('@affine/templates/v1/personal-knowledge-management.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'b7a9e1bc-e205-44aa-8dad-7e328269d00b',
|
||||
import('@affine/templates/v1/annual-performance-review.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'646305d9-93e0-48df-bb92-d82944ceb5a3',
|
||||
import('@affine/templates/v1/brief-event-planning.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'0350509d-8702-4797-b4d7-168f5e9359c7',
|
||||
import('@affine/templates/v1/meeting-summary.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'aa02af3c-5c5c-4856-b7ce-947ad17331f3',
|
||||
import('@affine/templates/v1/okr-template.json'),
|
||||
nanoid(),
|
||||
],
|
||||
[
|
||||
'9d6e716e-a071-45a2-88ac-2f2f6eec0109',
|
||||
import('@affine/templates/v1/travel-note.json'),
|
||||
edgelessPage3,
|
||||
],
|
||||
] as const;
|
||||
const idMap = await Promise.all(data).then(async data => {
|
||||
return data.reduce<Record<string, string>>(
|
||||
(record, currentValue) => {
|
||||
const [oldId, _, newId] = currentValue;
|
||||
record[oldId] = newId;
|
||||
return record;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
});
|
||||
await Promise.all(
|
||||
data.map(async ([id, promise, newId]) => {
|
||||
const { default: template } = await promise;
|
||||
let json = JSON.stringify(template);
|
||||
Object.entries(idMap).forEach(([oldId, newId]) => {
|
||||
json = json.replaceAll(oldId, newId);
|
||||
});
|
||||
json = JSON.parse(json);
|
||||
await workspace
|
||||
.importPageSnapshot(structuredClone(json), newId)
|
||||
.catch(error => {
|
||||
console.error('error importing page', id, error);
|
||||
});
|
||||
const page = workspace.getPage(newId);
|
||||
assertExists(page);
|
||||
await page.waitForLoaded();
|
||||
workspace.schema.upgradePage(
|
||||
0,
|
||||
{
|
||||
'affine:note': 1,
|
||||
'affine:bookmark': 1,
|
||||
'affine:database': 2,
|
||||
'affine:divider': 1,
|
||||
'affine:image': 1,
|
||||
'affine:list': 1,
|
||||
'affine:code': 1,
|
||||
'affine:page': 2,
|
||||
'affine:paragraph': 1,
|
||||
'affine:surface': 3,
|
||||
},
|
||||
page.spaceDoc
|
||||
);
|
||||
})
|
||||
);
|
||||
Object.entries(pageMetas).forEach(([oldId, meta]) => {
|
||||
const newId = idMap[oldId];
|
||||
workspace.setPageMeta(newId, meta);
|
||||
});
|
||||
}
|
||||
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
const migrationOrigin = 'affine-migration';
|
||||
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Schema } from '@blocksuite/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
type XYWH = [number, number, number, number];
|
||||
|
||||
function deserializeXYWH(xywh: string): XYWH {
|
||||
return JSON.parse(xywh) as XYWH;
|
||||
}
|
||||
|
||||
const getLatestVersions = (schema: Schema): Record<string, number> => {
|
||||
return [...schema.flavourSchemaMap.entries()].reduce(
|
||||
(record, [flavour, schema]) => {
|
||||
record[flavour] = schema.version;
|
||||
return record;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
};
|
||||
|
||||
function migrateDatabase(data: YMap<unknown>) {
|
||||
data.delete('prop:mode');
|
||||
data.set('prop:views', new YArray());
|
||||
const columns = (data.get('prop:columns') as YArray<unknown>).toJSON() as {
|
||||
id: string;
|
||||
name: string;
|
||||
hide: boolean;
|
||||
type: string;
|
||||
width: number;
|
||||
selection?: unknown[];
|
||||
}[];
|
||||
const views = [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Table',
|
||||
columns: columns.map(col => ({
|
||||
id: col.id,
|
||||
width: col.width,
|
||||
hide: col.hide,
|
||||
})),
|
||||
filter: { type: 'group', op: 'and', conditions: [] },
|
||||
mode: 'table',
|
||||
},
|
||||
];
|
||||
const cells = (data.get('prop:cells') as YMap<unknown>).toJSON() as Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
value: unknown;
|
||||
}
|
||||
>
|
||||
>;
|
||||
const convertColumn = (
|
||||
id: string,
|
||||
update: (cell: { id: string; value: unknown }) => void
|
||||
) => {
|
||||
Object.values(cells).forEach(row => {
|
||||
if (row[id] != null) {
|
||||
update(row[id]);
|
||||
}
|
||||
});
|
||||
};
|
||||
const newColumns = columns.map(v => {
|
||||
let data: Record<string, unknown> = {};
|
||||
if (v.type === 'select' || v.type === 'multi-select') {
|
||||
data = { options: v.selection };
|
||||
if (v.type === 'select') {
|
||||
convertColumn(v.id, cell => {
|
||||
if (Array.isArray(cell.value)) {
|
||||
cell.value = cell.value[0]?.id;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
convertColumn(v.id, cell => {
|
||||
if (Array.isArray(cell.value)) {
|
||||
cell.value = cell.value.map(v => v.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (v.type === 'number') {
|
||||
convertColumn(v.id, cell => {
|
||||
if (typeof cell.value === 'string') {
|
||||
cell.value = Number.parseFloat(cell.value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: v.id,
|
||||
type: v.type,
|
||||
name: v.name,
|
||||
data,
|
||||
};
|
||||
});
|
||||
data.set('prop:columns', newColumns);
|
||||
data.set('prop:views', views);
|
||||
data.set('prop:cells', cells);
|
||||
}
|
||||
|
||||
function runBlockMigration(
|
||||
flavour: string,
|
||||
data: YMap<unknown>,
|
||||
version: number
|
||||
) {
|
||||
if (flavour === 'affine:frame') {
|
||||
data.set('sys:flavour', 'affine:note');
|
||||
return;
|
||||
}
|
||||
if (flavour === 'affine:surface' && version <= 3) {
|
||||
if (data.has('elements')) {
|
||||
const elements = data.get('elements') as YMap<unknown>;
|
||||
migrateSurface(elements);
|
||||
data.set('prop:elements', elements.clone());
|
||||
data.delete('elements');
|
||||
} else {
|
||||
data.set('prop:elements', new YMap());
|
||||
}
|
||||
}
|
||||
if (flavour === 'affine:embed') {
|
||||
data.set('sys:flavour', 'affine:image');
|
||||
data.delete('prop:type');
|
||||
}
|
||||
if (flavour === 'affine:database' && version < 2) {
|
||||
migrateDatabase(data);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateSurface(data: YMap<unknown>) {
|
||||
for (const [, value] of <IterableIterator<[string, YMap<unknown>]>>(
|
||||
data.entries()
|
||||
)) {
|
||||
if (value.get('type') === 'connector') {
|
||||
migrateSurfaceConnector(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrateSurfaceConnector(data: YMap<any>) {
|
||||
let id = data.get('startElement')?.id;
|
||||
const controllers = data.get('controllers');
|
||||
const length = controllers.length;
|
||||
const xywh = deserializeXYWH(data.get('xywh'));
|
||||
if (id) {
|
||||
data.set('source', { id });
|
||||
} else {
|
||||
data.set('source', {
|
||||
position: [controllers[0].x + xywh[0], controllers[0].y + xywh[1]],
|
||||
});
|
||||
}
|
||||
|
||||
id = data.get('endElement')?.id;
|
||||
if (id) {
|
||||
data.set('target', { id });
|
||||
} else {
|
||||
data.set('target', {
|
||||
position: [
|
||||
controllers[length - 1].x + xywh[0],
|
||||
controllers[length - 1].y + xywh[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const width = data.get('lineWidth') ?? 4;
|
||||
data.set('strokeWidth', width);
|
||||
const color = data.get('color');
|
||||
data.set('stroke', color);
|
||||
|
||||
data.delete('startElement');
|
||||
data.delete('endElement');
|
||||
data.delete('controllers');
|
||||
data.delete('lineWidth');
|
||||
data.delete('color');
|
||||
data.delete('xywh');
|
||||
}
|
||||
|
||||
function updateBlockVersions(versions: YMap<number>) {
|
||||
const frameVersion = versions.get('affine:frame');
|
||||
if (frameVersion !== undefined) {
|
||||
versions.set('affine:note', frameVersion);
|
||||
versions.delete('affine:frame');
|
||||
}
|
||||
const embedVersion = versions.get('affine:embed');
|
||||
if (embedVersion !== undefined) {
|
||||
versions.set('affine:image', embedVersion);
|
||||
versions.delete('affine:embed');
|
||||
}
|
||||
const databaseVersion = versions.get('affine:database');
|
||||
if (databaseVersion !== undefined && databaseVersion < 2) {
|
||||
versions.set('affine:database', 2);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateMeta(
|
||||
oldDoc: YDoc,
|
||||
newDoc: YDoc,
|
||||
idMap: Record<string, string>
|
||||
) {
|
||||
const originalMeta = oldDoc.getMap('space:meta');
|
||||
const originalVersions = originalMeta.get('versions') as YMap<number>;
|
||||
const originalPages = originalMeta.get('pages') as YArray<YMap<unknown>>;
|
||||
const meta = newDoc.getMap('meta');
|
||||
const pages = new YArray();
|
||||
const blockVersions = originalVersions.clone();
|
||||
|
||||
meta.set('workspaceVersion', 1);
|
||||
meta.set('blockVersions', blockVersions);
|
||||
meta.set('pages', pages);
|
||||
meta.set('name', originalMeta.get('name') as string);
|
||||
|
||||
updateBlockVersions(blockVersions);
|
||||
const mapList = originalPages.map(page => {
|
||||
const map = new YMap();
|
||||
Array.from(page.entries())
|
||||
.filter(([key]) => key !== 'subpageIds')
|
||||
.forEach(([key, value]) => {
|
||||
if (key === 'id') {
|
||||
idMap[value] = nanoid();
|
||||
map.set(key, idMap[value]);
|
||||
} else {
|
||||
map.set(key, value);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
});
|
||||
pages.push(mapList);
|
||||
}
|
||||
|
||||
function migrateBlocks(
|
||||
oldDoc: YDoc,
|
||||
newDoc: YDoc,
|
||||
idMap: Record<string, string>
|
||||
) {
|
||||
const spaces = newDoc.getMap('spaces');
|
||||
const originalMeta = oldDoc.getMap('space:meta');
|
||||
const originalVersions = originalMeta.get('versions') as YMap<number>;
|
||||
const originalPages = originalMeta.get('pages') as YArray<YMap<unknown>>;
|
||||
originalPages.forEach(page => {
|
||||
const id = page.get('id') as string;
|
||||
const newId = idMap[id];
|
||||
const spaceId = id.startsWith('space:') ? id : `space:${id}`;
|
||||
const originalBlocks = oldDoc.getMap(spaceId) as YMap<unknown>;
|
||||
const subdoc = new YDoc();
|
||||
spaces.set(newId, subdoc);
|
||||
const blocks = subdoc.getMap('blocks');
|
||||
Array.from(originalBlocks.entries()).forEach(([key, value]) => {
|
||||
const blockData = value.clone();
|
||||
blocks.set(key, blockData);
|
||||
const flavour = blockData.get('sys:flavour') as string;
|
||||
const version = originalVersions.get(flavour);
|
||||
if (version !== undefined) {
|
||||
runBlockMigration(flavour, blockData, version);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function migrateToSubdoc(oldDoc: YDoc): YDoc {
|
||||
const needMigration =
|
||||
Array.from(oldDoc.getMap('space:meta').keys()).length > 0;
|
||||
if (!needMigration) {
|
||||
return oldDoc;
|
||||
}
|
||||
const newDoc = new YDoc();
|
||||
const idMap = {} as Record<string, string>;
|
||||
migrateMeta(oldDoc, newDoc, idMap);
|
||||
migrateBlocks(oldDoc, newDoc, idMap);
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
export type UpgradeOptions = {
|
||||
getCurrentRootDoc: () => Promise<YDoc>;
|
||||
createWorkspace: () => Promise<Workspace>;
|
||||
getSchema: () => Schema;
|
||||
};
|
||||
|
||||
const upgradeV1ToV2 = async (options: UpgradeOptions) => {
|
||||
const oldDoc = await options.getCurrentRootDoc();
|
||||
const newDoc = migrateToSubdoc(oldDoc);
|
||||
const newWorkspace = await options.createWorkspace();
|
||||
applyUpdate(newWorkspace.doc, encodeStateAsUpdate(newDoc), migrationOrigin);
|
||||
newDoc.getSubdocs().forEach(subdoc => {
|
||||
newWorkspace.doc.getSubdocs().forEach(newDoc => {
|
||||
if (subdoc.guid === newDoc.guid) {
|
||||
applyUpdate(newDoc, encodeStateAsUpdate(subdoc), migrationOrigin);
|
||||
}
|
||||
});
|
||||
});
|
||||
return newWorkspace;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force upgrade block schema to the latest.
|
||||
* Don't force to upgrade the pages without the check.
|
||||
*
|
||||
* Please note that this function will not upgrade the workspace version.
|
||||
*
|
||||
* @returns true if any schema is upgraded.
|
||||
* @returns false if no schema is upgraded.
|
||||
*/
|
||||
export async function forceUpgradePages(
|
||||
options: Omit<UpgradeOptions, 'createWorkspace'>
|
||||
): Promise<boolean> {
|
||||
const rootDoc = await options.getCurrentRootDoc();
|
||||
const spaces = rootDoc.getMap('spaces') as YMap<any>;
|
||||
const meta = rootDoc.getMap('meta') as YMap<unknown>;
|
||||
const versions = meta.get('blockVersions') as YMap<number>;
|
||||
const schema = options.getSchema();
|
||||
const oldVersions = versions.toJSON();
|
||||
spaces.forEach((space: Doc) => {
|
||||
try {
|
||||
schema.upgradePage(0, oldVersions, space);
|
||||
} catch (e) {
|
||||
console.error(`page ${space.guid} upgrade failed`, e);
|
||||
}
|
||||
});
|
||||
const newVersions = getLatestVersions(schema);
|
||||
meta.set('blockVersions', new YMap(Object.entries(newVersions)));
|
||||
return Object.entries(oldVersions).some(
|
||||
([flavour, version]) => newVersions[flavour] !== version
|
||||
);
|
||||
}
|
||||
|
||||
// database from 2 to 3
|
||||
async function upgradeV2ToV3(options: UpgradeOptions): Promise<boolean> {
|
||||
const rootDoc = await options.getCurrentRootDoc();
|
||||
const spaces = rootDoc.getMap('spaces') as YMap<any>;
|
||||
const meta = rootDoc.getMap('meta') as YMap<unknown>;
|
||||
const versions = meta.get('blockVersions') as YMap<number>;
|
||||
const schema = options.getSchema();
|
||||
spaces.forEach((space: Doc) => {
|
||||
schema.upgradePage(
|
||||
0,
|
||||
{
|
||||
'affine:note': 1,
|
||||
'affine:bookmark': 1,
|
||||
'affine:database': 2,
|
||||
'affine:divider': 1,
|
||||
'affine:image': 1,
|
||||
'affine:list': 1,
|
||||
'affine:code': 1,
|
||||
'affine:page': 2,
|
||||
'affine:paragraph': 1,
|
||||
'affine:surface': 3,
|
||||
},
|
||||
space
|
||||
);
|
||||
});
|
||||
if ('affine:database' in versions) {
|
||||
meta.set(
|
||||
'blockVersions',
|
||||
new YMap(Object.entries(getLatestVersions(schema)))
|
||||
);
|
||||
} else {
|
||||
Object.entries(getLatestVersions(schema)).map(([flavour, version]) =>
|
||||
versions.set(flavour, version)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export enum WorkspaceVersion {
|
||||
// v1 is treated as undefined
|
||||
SubDoc = 2,
|
||||
DatabaseV3 = 3,
|
||||
Surface = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* If returns false, it means no migration is needed.
|
||||
* If returns true, it means migration is done.
|
||||
* If returns Workspace, it means new workspace is created,
|
||||
* and the old workspace should be deleted.
|
||||
*/
|
||||
export async function migrateWorkspace(
|
||||
currentVersion: WorkspaceVersion | undefined,
|
||||
options: UpgradeOptions
|
||||
): Promise<Workspace | boolean> {
|
||||
if (currentVersion === undefined) {
|
||||
const workspace = await upgradeV1ToV2(options);
|
||||
await upgradeV2ToV3({
|
||||
...options,
|
||||
getCurrentRootDoc: () => Promise.resolve(workspace.doc),
|
||||
});
|
||||
return workspace;
|
||||
}
|
||||
if (currentVersion === WorkspaceVersion.SubDoc) {
|
||||
return upgradeV2ToV3(options);
|
||||
} else if (currentVersion === WorkspaceVersion.DatabaseV3) {
|
||||
// surface from 3 to 5
|
||||
return forceUpgradePages(options);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateLocalBlobStorage(from: string, to: string) {
|
||||
const fromStorage = createIndexeddbStorage(from);
|
||||
const toStorage = createIndexeddbStorage(to);
|
||||
const keys = await fromStorage.crud.list();
|
||||
for (const key of keys) {
|
||||
const value = await fromStorage.crud.get(key);
|
||||
if (!value) {
|
||||
console.warn('cannot find blob:', key);
|
||||
continue;
|
||||
}
|
||||
await toStorage.crud.set(key, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# AFFiNE Command Abstractions
|
||||
|
||||
This package contains the command abstractions for the AFFiNE framework to be used for CMD-K.
|
||||
|
||||
The implementation is highly inspired by the [VSCode Command Abstractions](https://github.com/microsoft/vscode)
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// TODO: need better way for composing different precondition strategies
|
||||
export enum PreconditionStrategy {
|
||||
Always,
|
||||
InPaperOrEdgeless,
|
||||
InPaper,
|
||||
InEdgeless,
|
||||
InEdgelessPresentationMode,
|
||||
NoSearchResult,
|
||||
Never,
|
||||
}
|
||||
|
||||
export type CommandCategory =
|
||||
| 'editor:insert-object'
|
||||
| 'editor:page'
|
||||
| 'editor:edgeless'
|
||||
| 'affine:recent'
|
||||
| 'affine:pages'
|
||||
| 'affine:edgeless'
|
||||
| 'affine:collections'
|
||||
| 'affine:navigation'
|
||||
| 'affine:creation'
|
||||
| 'affine:settings'
|
||||
| 'affine:layout'
|
||||
| 'affine:updates'
|
||||
| 'affine:help'
|
||||
| 'affine:general';
|
||||
|
||||
export interface KeybindingOptions {
|
||||
binding: string;
|
||||
// some keybindings are already registered in blocksuite
|
||||
// we can skip the registration of these keybindings __FOR NOW__
|
||||
skipRegister?: boolean;
|
||||
}
|
||||
|
||||
export interface AffineCommandOptions {
|
||||
id: string;
|
||||
// a set of predefined precondition strategies, but also allow user to customize their own
|
||||
preconditionStrategy?: PreconditionStrategy | (() => boolean);
|
||||
// main text on the left..
|
||||
// make text a function so that we can do i18n and interpolation when we need to
|
||||
label?: string | (() => string) | ReactNode | (() => ReactNode);
|
||||
icon: React.ReactNode; // todo: need a mapping from string -> React element/SVG
|
||||
category?: CommandCategory;
|
||||
// we use https://github.com/jamiebuilds/tinykeys so that we can use the same keybinding definition
|
||||
// for both mac and windows
|
||||
// todo: render keybinding in command palette
|
||||
keyBinding?: KeybindingOptions | string;
|
||||
run: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AffineCommand {
|
||||
readonly id: string;
|
||||
readonly preconditionStrategy: PreconditionStrategy | (() => boolean);
|
||||
readonly label?: ReactNode | string;
|
||||
readonly icon?: React.ReactNode; // icon name
|
||||
readonly category: CommandCategory;
|
||||
readonly keyBinding?: KeybindingOptions;
|
||||
run(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export function createAffineCommand(
|
||||
options: AffineCommandOptions
|
||||
): AffineCommand {
|
||||
return {
|
||||
id: options.id,
|
||||
run: options.run,
|
||||
icon: options.icon,
|
||||
preconditionStrategy:
|
||||
options.preconditionStrategy ?? PreconditionStrategy.Always,
|
||||
category: options.category ?? 'affine:general',
|
||||
get label() {
|
||||
const label = options.label;
|
||||
return typeof label === 'function' ? label?.() : label;
|
||||
},
|
||||
keyBinding:
|
||||
typeof options.keyBinding === 'string'
|
||||
? { binding: options.keyBinding }
|
||||
: options.keyBinding,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './command';
|
||||
export * from './registry';
|
||||
@@ -0,0 +1,64 @@
|
||||
// @ts-expect-error upstream type is wrong
|
||||
import { tinykeys } from 'tinykeys';
|
||||
|
||||
import {
|
||||
type AffineCommand,
|
||||
type AffineCommandOptions,
|
||||
createAffineCommand,
|
||||
} from './command';
|
||||
|
||||
export const AffineCommandRegistry = new (class {
|
||||
readonly commands: Map<string, AffineCommand> = new Map();
|
||||
|
||||
register(options: AffineCommandOptions) {
|
||||
if (this.commands.has(options.id)) {
|
||||
console.warn(`Command ${options.id} already registered.`);
|
||||
return () => {};
|
||||
}
|
||||
const command = createAffineCommand(options);
|
||||
this.commands.set(command.id, command);
|
||||
|
||||
let unsubKb: (() => void) | undefined;
|
||||
|
||||
if (
|
||||
command.keyBinding &&
|
||||
!command.keyBinding.skipRegister &&
|
||||
typeof window !== 'undefined'
|
||||
) {
|
||||
const { binding: keybinding } = command.keyBinding;
|
||||
unsubKb = tinykeys(window, {
|
||||
[keybinding]: async (e: Event) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await command.run();
|
||||
} catch (e) {
|
||||
console.error(`Failed to invoke keybinding [${keybinding}]`, e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.debug(`Registered command ${command.id}`);
|
||||
return () => {
|
||||
unsubKb?.();
|
||||
this.commands.delete(command.id);
|
||||
console.debug(`Unregistered command ${command.id}`);
|
||||
};
|
||||
}
|
||||
|
||||
get(id: string): AffineCommand | undefined {
|
||||
if (!this.commands.has(id)) {
|
||||
console.warn(`Command ${id} not registered.`);
|
||||
return undefined;
|
||||
}
|
||||
return this.commands.get(id);
|
||||
}
|
||||
|
||||
getAll(): AffineCommand[] {
|
||||
return Array.from(this.commands.values());
|
||||
}
|
||||
})();
|
||||
|
||||
export function registerAffineCommand(options: AffineCommandOptions) {
|
||||
return AffineCommandRegistry.register(options);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2018 Andy Wermke
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
export type EventMap = {
|
||||
[key: string]: (...args: any[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type-safe event emitter.
|
||||
*
|
||||
* Use it like this:
|
||||
*
|
||||
* ```typescript
|
||||
* type MyEvents = {
|
||||
* error: (error: Error) => void;
|
||||
* message: (from: string, content: string) => void;
|
||||
* }
|
||||
*
|
||||
* const myEmitter = new EventEmitter() as TypedEmitter<MyEvents>;
|
||||
*
|
||||
* myEmitter.emit("error", "x") // <- Will catch this type error;
|
||||
* ```
|
||||
*
|
||||
* Lifecycle:
|
||||
* invoke -> handle -> emit -> on/once
|
||||
*/
|
||||
export interface TypedEventEmitter<Events extends EventMap> {
|
||||
addListener<E extends keyof Events>(event: E, listener: Events[E]): this;
|
||||
on<E extends keyof Events>(event: E, listener: Events[E]): this;
|
||||
once<E extends keyof Events>(event: E, listener: Events[E]): this;
|
||||
|
||||
off<E extends keyof Events>(event: E, listener: Events[E]): this;
|
||||
removeAllListeners<E extends keyof Events>(event?: E): this;
|
||||
removeListener<E extends keyof Events>(event: E, listener: Events[E]): this;
|
||||
|
||||
emit<E extends keyof Events>(
|
||||
event: E,
|
||||
...args: Parameters<Events[E]>
|
||||
): boolean;
|
||||
// The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5
|
||||
eventNames(): (keyof Events | string | symbol)[];
|
||||
rawListeners<E extends keyof Events>(event: E): Events[E][];
|
||||
listeners<E extends keyof Events>(event: E): Events[E][];
|
||||
listenerCount<E extends keyof Events>(event: E): number;
|
||||
|
||||
handle<E extends keyof Events>(event: E, handler: Events[E]): this;
|
||||
invoke<E extends keyof Events>(
|
||||
event: E,
|
||||
...args: Parameters<Events[E]>
|
||||
): Promise<ReturnType<Events[E]>>;
|
||||
|
||||
getMaxListeners(): number;
|
||||
setMaxListeners(maxListeners: number): this;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
ClipboardHandlers,
|
||||
DBHandlers,
|
||||
DebugHandlers,
|
||||
DialogHandlers,
|
||||
ExportHandlers,
|
||||
UIHandlers,
|
||||
UpdaterHandlers,
|
||||
WorkspaceHandlers,
|
||||
} from './type.js';
|
||||
import { HandlerManager } from './type.js';
|
||||
|
||||
export abstract class DBHandlerManager extends HandlerManager<
|
||||
'db',
|
||||
DBHandlers
|
||||
> {}
|
||||
|
||||
export abstract class DebugHandlerManager extends HandlerManager<
|
||||
'debug',
|
||||
DebugHandlers
|
||||
> {}
|
||||
|
||||
export abstract class DialogHandlerManager extends HandlerManager<
|
||||
'dialog',
|
||||
DialogHandlers
|
||||
> {}
|
||||
|
||||
export abstract class UIHandlerManager extends HandlerManager<
|
||||
'ui',
|
||||
UIHandlers
|
||||
> {}
|
||||
|
||||
export abstract class ClipboardHandlerManager extends HandlerManager<
|
||||
'clipboard',
|
||||
ClipboardHandlers
|
||||
> {}
|
||||
|
||||
export abstract class ExportHandlerManager extends HandlerManager<
|
||||
'export',
|
||||
ExportHandlers
|
||||
> {}
|
||||
|
||||
export abstract class UpdaterHandlerManager extends HandlerManager<
|
||||
'updater',
|
||||
UpdaterHandlers
|
||||
> {}
|
||||
|
||||
export abstract class WorkspaceHandlerManager extends HandlerManager<
|
||||
'workspace',
|
||||
WorkspaceHandlers
|
||||
> {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './handler.js';
|
||||
export * from './type.js';
|
||||
@@ -0,0 +1,230 @@
|
||||
// Please add modules to `external` in `rollupOptions` to avoid wrong bundling.
|
||||
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
|
||||
import type { app, dialog, shell } from 'electron';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { Subject } from 'rxjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ExposedMeta {
|
||||
handlers: [string, string[]][];
|
||||
events: [string, string[]][];
|
||||
}
|
||||
|
||||
// render <-> helper
|
||||
export interface RendererToHelper {
|
||||
postEvent: (channel: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
export interface HelperToRenderer {
|
||||
[key: string]: (...args: any[]) => Promise<any>;
|
||||
}
|
||||
|
||||
// helper <-> main
|
||||
export interface HelperToMain {
|
||||
getMeta: () => ExposedMeta;
|
||||
}
|
||||
|
||||
export type MainToHelper = Pick<
|
||||
typeof dialog & typeof shell & typeof app,
|
||||
| 'showOpenDialog'
|
||||
| 'showSaveDialog'
|
||||
| 'openExternal'
|
||||
| 'showItemInFolder'
|
||||
| 'getPath'
|
||||
>;
|
||||
|
||||
export function getElectronAPIs() {
|
||||
const mainAPIs = getMainAPIs();
|
||||
const helperAPIs = getHelperAPIs();
|
||||
|
||||
return {
|
||||
apis: {
|
||||
...mainAPIs.apis,
|
||||
...helperAPIs.apis,
|
||||
},
|
||||
events: {
|
||||
...mainAPIs.events,
|
||||
...helperAPIs.events,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// todo: remove duplicated codes
|
||||
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
|
||||
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
|
||||
const buildType = ReleaseTypeSchema.parse(envBuildType);
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
let schema = buildType === 'stable' ? 'affine' : `affine-${envBuildType}`;
|
||||
schema = isDev ? 'affine-dev' : schema;
|
||||
|
||||
export const appInfo = {
|
||||
electron: true,
|
||||
schema,
|
||||
};
|
||||
|
||||
function getMainAPIs() {
|
||||
const meta: ExposedMeta = (() => {
|
||||
const val = process.argv
|
||||
.find(arg => arg.startsWith('--main-exposed-meta='))
|
||||
?.split('=')[1];
|
||||
|
||||
return val ? JSON.parse(val) : null;
|
||||
})();
|
||||
|
||||
// main handlers that can be invoked from the renderer process
|
||||
const apis: any = (() => {
|
||||
const { handlers: handlersMeta } = meta;
|
||||
|
||||
const all = handlersMeta.map(([namespace, functionNames]) => {
|
||||
const namespaceApis = functionNames.map(name => {
|
||||
const channel = `${namespace}:${name}`;
|
||||
return [
|
||||
name,
|
||||
(...args: any[]) => {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
];
|
||||
});
|
||||
return [namespace, Object.fromEntries(namespaceApis)];
|
||||
});
|
||||
|
||||
return Object.fromEntries(all);
|
||||
})();
|
||||
|
||||
// main events that can be listened to from the renderer process
|
||||
const events: any = (() => {
|
||||
const { events: eventsMeta } = meta;
|
||||
|
||||
// NOTE: ui may try to listen to a lot of the same events, so we increase the limit...
|
||||
ipcRenderer.setMaxListeners(100);
|
||||
|
||||
const all = eventsMeta.map(([namespace, eventNames]) => {
|
||||
const namespaceEvents = eventNames.map(name => {
|
||||
const channel = `${namespace}:${name}`;
|
||||
return [
|
||||
name,
|
||||
(callback: (...args: any[]) => void) => {
|
||||
const fn: (
|
||||
event: Electron.IpcRendererEvent,
|
||||
...args: any[]
|
||||
) => void = (_, ...args) => {
|
||||
callback(...args);
|
||||
};
|
||||
ipcRenderer.on(channel, fn);
|
||||
return () => {
|
||||
ipcRenderer.off(channel, fn);
|
||||
};
|
||||
},
|
||||
];
|
||||
});
|
||||
return [namespace, Object.fromEntries(namespaceEvents)];
|
||||
});
|
||||
return Object.fromEntries(all);
|
||||
})();
|
||||
|
||||
return { apis, events };
|
||||
}
|
||||
|
||||
const helperPort$ = new Promise<MessagePort>(resolve =>
|
||||
ipcRenderer.on('helper-connection', e => {
|
||||
console.info('[preload] helper-connection', e);
|
||||
resolve(e.ports[0]);
|
||||
})
|
||||
);
|
||||
|
||||
const createMessagePortChannel = (port: MessagePort): EventBasedChannel => {
|
||||
return {
|
||||
on(listener) {
|
||||
port.onmessage = e => {
|
||||
listener(e.data);
|
||||
};
|
||||
port.start();
|
||||
return () => {
|
||||
port.onmessage = null;
|
||||
port.close();
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
port.postMessage(data);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function getHelperAPIs() {
|
||||
const events$ = new Subject<{ channel: string; args: any[] }>();
|
||||
const meta: ExposedMeta | null = (() => {
|
||||
const val = process.argv
|
||||
.find(arg => arg.startsWith('--helper-exposed-meta='))
|
||||
?.split('=')[1];
|
||||
|
||||
return val ? JSON.parse(val) : null;
|
||||
})();
|
||||
|
||||
const rendererToHelperServer: RendererToHelper = {
|
||||
postEvent: (channel, ...args) => {
|
||||
events$.next({ channel, args });
|
||||
},
|
||||
};
|
||||
|
||||
const rpc = AsyncCall<HelperToRenderer>(rendererToHelperServer, {
|
||||
channel: helperPort$.then(helperPort =>
|
||||
createMessagePortChannel(helperPort)
|
||||
),
|
||||
log: false,
|
||||
});
|
||||
|
||||
const toHelperHandler = (namespace: string, name: string) => {
|
||||
return rpc[`${namespace}:${name}`];
|
||||
};
|
||||
|
||||
const toHelperEventSubscriber = (namespace: string, name: string) => {
|
||||
return (callback: (...args: any[]) => void) => {
|
||||
const subscription = events$.subscribe(({ channel, args }) => {
|
||||
if (channel === `${namespace}:${name}`) {
|
||||
callback(...args);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const setup = (meta: ExposedMeta) => {
|
||||
const { handlers, events } = meta;
|
||||
|
||||
const helperHandlers = Object.fromEntries(
|
||||
handlers.map(([namespace, functionNames]) => {
|
||||
return [
|
||||
namespace,
|
||||
Object.fromEntries(
|
||||
functionNames.map(name => {
|
||||
return [name, toHelperHandler(namespace, name)];
|
||||
})
|
||||
),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const helperEvents = Object.fromEntries(
|
||||
events.map(([namespace, eventNames]) => {
|
||||
return [
|
||||
namespace,
|
||||
Object.fromEntries(
|
||||
eventNames.map(name => {
|
||||
return [name, toHelperEventSubscriber(namespace, name)];
|
||||
})
|
||||
),
|
||||
];
|
||||
})
|
||||
);
|
||||
return [helperHandlers, helperEvents];
|
||||
};
|
||||
|
||||
if (meta) {
|
||||
const [apis, events] = setup(meta);
|
||||
return { apis, events };
|
||||
} else {
|
||||
return { apis: {}, events: {} };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { ExpectedLayout } from '@affine/sdk/entry';
|
||||
import type Buffer from 'buffer';
|
||||
import type { WritableAtom } from 'jotai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TypedEventEmitter } from './core/event-emitter.js';
|
||||
|
||||
type Buffer = Buffer.Buffer;
|
||||
|
||||
export const packageJsonInputSchema = z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string(),
|
||||
affinePlugin: z.object({
|
||||
release: z.union([z.boolean(), z.enum(['development'])]),
|
||||
entry: z.object({
|
||||
core: z.string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const packageJsonOutputSchema = z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string(),
|
||||
affinePlugin: z.object({
|
||||
release: z.union([z.boolean(), z.enum(['development'])]),
|
||||
entry: z.object({
|
||||
core: z.string(),
|
||||
}),
|
||||
assets: z.array(z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
type SetStateAction<Value> = Value | ((prev: Value) => Value);
|
||||
|
||||
export type ContentLayoutAtom = WritableAtom<
|
||||
ExpectedLayout,
|
||||
[SetStateAction<ExpectedLayout>],
|
||||
void
|
||||
>;
|
||||
|
||||
export abstract class HandlerManager<
|
||||
Namespace extends string,
|
||||
Handlers extends Record<string, PrimitiveHandlers>,
|
||||
> {
|
||||
static instance: HandlerManager<string, Record<string, PrimitiveHandlers>>;
|
||||
private _app: App<Namespace, Handlers>;
|
||||
private _namespace: Namespace;
|
||||
private _handlers: Handlers;
|
||||
|
||||
constructor() {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
private _initialized = false;
|
||||
|
||||
registerHandlers(handlers: Handlers) {
|
||||
if (this._initialized) {
|
||||
throw new Error('Already initialized');
|
||||
}
|
||||
this._handlers = handlers;
|
||||
for (const [name, handler] of Object.entries(this._handlers)) {
|
||||
this._app.handle(`${this._namespace}:${name}`, (async (...args: any[]) =>
|
||||
handler(...args)) as any);
|
||||
}
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
invokeHandler<K extends keyof Handlers>(
|
||||
name: K,
|
||||
...args: Parameters<Handlers[K]>
|
||||
): Promise<ReturnType<Handlers[K]>> {
|
||||
return this._handlers[name](...args);
|
||||
}
|
||||
|
||||
static getInstance(): HandlerManager<
|
||||
string,
|
||||
Record<string, PrimitiveHandlers>
|
||||
> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceMeta {
|
||||
id: string;
|
||||
mainDBPath: string;
|
||||
secondaryDBPath?: string; // assume there will be only one
|
||||
}
|
||||
|
||||
export type PrimitiveHandlers = (...args: any[]) => Promise<any>;
|
||||
|
||||
export type DBHandlers = {
|
||||
getDocAsUpdates: (
|
||||
workspaceId: string,
|
||||
subdocId?: string
|
||||
) => Promise<Uint8Array | false>;
|
||||
applyDocUpdate: (
|
||||
id: string,
|
||||
update: Uint8Array,
|
||||
subdocId?: string
|
||||
) => Promise<void>;
|
||||
addBlob: (
|
||||
workspaceId: string,
|
||||
key: string,
|
||||
data: Uint8Array
|
||||
) => Promise<void>;
|
||||
getBlob: (workspaceId: string, key: string) => Promise<Buffer | null>;
|
||||
deleteBlob: (workspaceId: string, key: string) => Promise<void>;
|
||||
getBlobKeys: (workspaceId: string) => Promise<string[]>;
|
||||
getDefaultStorageLocation: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type DebugHandlers = {
|
||||
revealLogFile: () => Promise<string>;
|
||||
logFilePath: () => Promise<string>;
|
||||
};
|
||||
|
||||
export type ErrorMessage =
|
||||
| 'DB_FILE_ALREADY_LOADED'
|
||||
| 'DB_FILE_PATH_INVALID'
|
||||
| 'DB_FILE_INVALID'
|
||||
| 'DB_FILE_MIGRATION_FAILED'
|
||||
| 'FILE_ALREADY_EXISTS'
|
||||
| 'UNKNOWN_ERROR';
|
||||
|
||||
export interface LoadDBFileResult {
|
||||
workspaceId?: string;
|
||||
error?: ErrorMessage;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export interface SaveDBFileResult {
|
||||
filePath?: string;
|
||||
canceled?: boolean;
|
||||
error?: ErrorMessage;
|
||||
}
|
||||
|
||||
export interface SelectDBFileLocationResult {
|
||||
filePath?: string;
|
||||
error?: ErrorMessage;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export interface MoveDBFileResult {
|
||||
filePath?: string;
|
||||
error?: ErrorMessage;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
// provide a backdoor to set dialog path for testing in playwright
|
||||
export interface FakeDialogResult {
|
||||
canceled?: boolean;
|
||||
filePath?: string;
|
||||
filePaths?: string[];
|
||||
}
|
||||
|
||||
export type DialogHandlers = {
|
||||
revealDBFile: (workspaceId: string) => Promise<void>;
|
||||
loadDBFile: () => Promise<LoadDBFileResult>;
|
||||
saveDBFileAs: (workspaceId: string) => Promise<SaveDBFileResult>;
|
||||
moveDBFile: (
|
||||
workspaceId: string,
|
||||
dbFileLocation?: string
|
||||
) => Promise<MoveDBFileResult>;
|
||||
selectDBFileLocation: () => Promise<SelectDBFileLocationResult>;
|
||||
setFakeDialogResult: (result: any) => Promise<void>;
|
||||
};
|
||||
|
||||
export type UIHandlers = {
|
||||
handleThemeChange: (theme: 'system' | 'light' | 'dark') => Promise<any>;
|
||||
handleSidebarVisibilityChange: (visible: boolean) => Promise<any>;
|
||||
handleMinimizeApp: () => Promise<any>;
|
||||
handleMaximizeApp: () => Promise<any>;
|
||||
handleCloseApp: () => Promise<any>;
|
||||
getGoogleOauthCode: () => Promise<any>;
|
||||
getChallengeResponse: (resource: string) => Promise<string>;
|
||||
};
|
||||
|
||||
export type ClipboardHandlers = {
|
||||
copyAsImageFromString: (dataURL: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type ExportHandlers = {
|
||||
savePDFFileAs: (title: string) => Promise<any>;
|
||||
};
|
||||
|
||||
export interface UpdateMeta {
|
||||
version: string;
|
||||
allowAutoUpdate: boolean;
|
||||
}
|
||||
|
||||
export type UpdaterHandlers = {
|
||||
currentVersion: () => Promise<string>;
|
||||
quitAndInstall: () => Promise<void>;
|
||||
checkForUpdatesAndNotify: () => Promise<{ version: string } | null>;
|
||||
};
|
||||
|
||||
export type WorkspaceHandlers = {
|
||||
list: () => Promise<[workspaceId: string, meta: WorkspaceMeta][]>;
|
||||
delete: (id: string) => Promise<void>;
|
||||
getMeta: (id: string) => Promise<WorkspaceMeta>;
|
||||
};
|
||||
|
||||
export type UnwrapManagerHandlerToServerSide<
|
||||
ElectronEvent extends {
|
||||
frameId: number;
|
||||
processId: number;
|
||||
},
|
||||
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>,
|
||||
> = Manager extends HandlerManager<infer _, infer Handlers>
|
||||
? {
|
||||
[K in keyof Handlers]: Handlers[K] extends (
|
||||
...args: infer Args
|
||||
) => Promise<infer R>
|
||||
? (event: ElectronEvent, ...args: Args) => Promise<R>
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type UnwrapManagerHandlerToClientSide<
|
||||
Manager extends HandlerManager<string, Record<string, PrimitiveHandlers>>,
|
||||
> = Manager extends HandlerManager<infer _, infer Handlers>
|
||||
? {
|
||||
[K in keyof Handlers]: Handlers[K] extends (
|
||||
...args: infer Args
|
||||
) => Promise<infer R>
|
||||
? (...args: Args) => Promise<R>
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type App<
|
||||
Namespace extends string,
|
||||
Handlers extends Record<string, PrimitiveHandlers>,
|
||||
> = TypedEventEmitter<{
|
||||
[K in keyof Handlers as `${Namespace}:${K & string}`]: Handlers[K];
|
||||
}>;
|
||||
|
||||
export interface UpdaterEvents {
|
||||
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => () => void;
|
||||
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => () => void;
|
||||
onDownloadProgress: (fn: (progress: number) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface ApplicationMenuEvents {
|
||||
onNewPageAction: (fn: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DBEvents {
|
||||
onExternalUpdate: (
|
||||
fn: (update: {
|
||||
workspaceId: string;
|
||||
update: Uint8Array;
|
||||
docId?: string;
|
||||
}) => void
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceEvents {
|
||||
onMetaChange: (
|
||||
fn: (workspaceId: string, meta: WorkspaceMeta) => void
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
export interface UIEvents {
|
||||
onMaximized: (fn: (maximized: boolean) => void) => () => void;
|
||||
}
|
||||
|
||||
export interface EventMap {
|
||||
updater: UpdaterEvents;
|
||||
applicationMenu: ApplicationMenuEvents;
|
||||
db: DBEvents;
|
||||
ui: UIEvents;
|
||||
workspace: WorkspaceEvents;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../sdk"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "lib",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../tests/fixtures"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
const root = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
minify: false,
|
||||
lib: {
|
||||
entry: {
|
||||
blocksuite: resolve(root, 'src/blocksuite/index.ts'),
|
||||
index: resolve(root, 'src/index.ts'),
|
||||
atom: resolve(root, 'src/atom.ts'),
|
||||
command: resolve(root, 'src/command/index.ts'),
|
||||
type: resolve(root, 'src/type.ts'),
|
||||
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
|
||||
'preload/electron': resolve(root, 'src/preload/electron.ts'),
|
||||
'__internal__/workspace': resolve(
|
||||
root,
|
||||
'src/__internal__/workspace.ts'
|
||||
),
|
||||
'__internal__/react': resolve(root, 'src/__internal__/react.ts'),
|
||||
'__internal__/plugin': resolve(root, 'src/__internal__/plugin.ts'),
|
||||
},
|
||||
formats: ['es', 'cjs'],
|
||||
name: 'AffineInfra',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'electron',
|
||||
'async-call-rpc',
|
||||
'rxjs',
|
||||
'zod',
|
||||
'react',
|
||||
'yjs',
|
||||
'nanoid',
|
||||
/^jotai/,
|
||||
/^@blocksuite/,
|
||||
/^@affine\/templates/,
|
||||
],
|
||||
},
|
||||
},
|
||||
plugins: [dts()],
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
src/entry.d.ts
|
||||
src/entry.d.ts.map
|
||||
src/entry.js
|
||||
src/entry.js.map
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@affine/sdk",
|
||||
"version": "0.10.0-canary.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"exports": {
|
||||
"./entry": {
|
||||
"types": "./dist/src/entry.d.ts",
|
||||
"import": "./dist/entry.js",
|
||||
"require": "./dist/entry.cjs"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/src/server.d.ts",
|
||||
"import": "./dist/server.js",
|
||||
"require": "./dist/server.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@blocksuite/block-std": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"jotai": "^2.4.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "3.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "packages/common/sdk/src",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"options": {
|
||||
"outputPath": "packages/common/sdk/dist"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/vite:build",
|
||||
"options": {
|
||||
"outputPath": "packages/common/sdk/dist",
|
||||
"watch": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["infra"]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BaseSelection } from '@blocksuite/block-std';
|
||||
import type { EditorContainer } from '@blocksuite/editor';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import type { Atom, getDefaultStore } from 'jotai/vanilla';
|
||||
import type { WritableAtom } from 'jotai/vanilla/atom';
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
export type Part = 'headerItem' | 'editor' | 'setting' | 'formatBar';
|
||||
|
||||
export type CallbackMap = {
|
||||
headerItem: (root: HTMLElement) => () => void;
|
||||
editor: (root: HTMLElement, editor: EditorContainer) => () => void;
|
||||
setting: (root: HTMLElement) => () => void;
|
||||
formatBar: (
|
||||
root: HTMLElement,
|
||||
page: Page,
|
||||
getBlockRange: () => BaseSelection[]
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
export interface PluginContext {
|
||||
register: <T extends Part>(part: T, callback: CallbackMap[T]) => void;
|
||||
utils: {
|
||||
PluginProvider: FunctionComponent; // make more clear
|
||||
};
|
||||
}
|
||||
|
||||
export type LayoutDirection = 'horizontal' | 'vertical';
|
||||
export type LayoutNode = LayoutParentNode | string;
|
||||
export type LayoutParentNode = {
|
||||
direction: LayoutDirection;
|
||||
splitPercentage: number; // 0 - 100
|
||||
first: string;
|
||||
second: LayoutNode;
|
||||
maxWidth?: (number | undefined)[];
|
||||
};
|
||||
|
||||
export type ExpectedLayout =
|
||||
| {
|
||||
direction: 'horizontal';
|
||||
// the first element is always the editor
|
||||
first: 'editor';
|
||||
second: LayoutNode;
|
||||
// the percentage should be greater than 70
|
||||
splitPercentage: number;
|
||||
}
|
||||
| 'editor';
|
||||
|
||||
export declare const pushLayoutAtom: WritableAtom<
|
||||
null,
|
||||
| [
|
||||
string,
|
||||
(div: HTMLDivElement) => () => void,
|
||||
{
|
||||
maxWidth: (number | undefined)[];
|
||||
},
|
||||
]
|
||||
| [string, (div: HTMLDivElement) => () => void],
|
||||
void
|
||||
>;
|
||||
export declare const deleteLayoutAtom: WritableAtom<null, [string], void>;
|
||||
export declare const currentPageIdAtom: Atom<string | null>;
|
||||
export declare const currentWorkspaceAtom: Atom<Promise<Workspace>>;
|
||||
export declare const rootStore: ReturnType<typeof getDefaultStore>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ServerContext {
|
||||
registerCommand: (command: string, fn: (...args: any[]) => any) => void;
|
||||
unregisterCommand: (command: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "lib",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
const root = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
minify: false,
|
||||
lib: {
|
||||
entry: {
|
||||
entry: resolve(root, 'src/entry.ts'),
|
||||
server: resolve(root, 'src/server.ts'),
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [/^jotai/, /^@blocksuite/, 'zod'],
|
||||
},
|
||||
},
|
||||
plugins: [dts()],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
This package has been moved to [toeverything/design](https://github.com/toeverything/design)
|
||||
@@ -0,0 +1 @@
|
||||
lib
|
||||
@@ -0,0 +1,38 @@
|
||||
# @toeverything/y-indexeddb
|
||||
|
||||
## Features
|
||||
|
||||
- persistence data in indexeddb
|
||||
- sub-documents support
|
||||
- fully TypeScript
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createIndexedDBProvider, downloadBinary } from '@toeverything/y-indexeddb';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const yDoc = new Y.Doc({
|
||||
// we use `guid` as unique key
|
||||
guid: 'my-doc',
|
||||
});
|
||||
|
||||
// sync yDoc with indexedDB
|
||||
const provider = createIndexedDBProvider(yDoc);
|
||||
provider.connect();
|
||||
await provider.whenSynced.then(() => {
|
||||
console.log('synced');
|
||||
provider.disconnect();
|
||||
});
|
||||
|
||||
// dowload binary data from indexedDB for once
|
||||
downloadBinary(yDoc.guid).then(blob => {
|
||||
if (blob !== false) {
|
||||
Y.applyUpdate(yDoc, blob);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## LICENSE
|
||||
|
||||
[MIT](https://github.com/toeverything/AFFiNE/blob/master/LICENSE-MIT)
|
||||
@@ -0,0 +1 @@
|
||||
This benchmark is outdated because our new API for IndexedDB has no direct parity with the official provider.
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env ts-node-esm
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
const map = new Map();
|
||||
const localStorage = {
|
||||
getItem: (key: string) => map.get(key),
|
||||
setItem: (key: string, value: string) => map.set(key, value),
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
globalThis.localStorage = localStorage;
|
||||
|
||||
import { Workspace } from '@blocksuite/store';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
|
||||
const Y = Workspace.Y;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { createIndexedDBProvider } from '../dist/index.js';
|
||||
|
||||
// @ts-expect-error
|
||||
globalThis.window = {
|
||||
addEventListener: () => void 0,
|
||||
removeEventListener: () => void 0,
|
||||
};
|
||||
|
||||
async function yjs_create_persistence(n = 1e3) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const yDoc = new Y.Doc();
|
||||
const persistence = new IndexeddbPersistence('test', yDoc);
|
||||
await persistence.whenSynced;
|
||||
persistence.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function yjs_single_persistence(n = 1e5) {
|
||||
const yDoc = new Y.Doc();
|
||||
const map = yDoc.getMap();
|
||||
for (let i = 0; i < n; i++) {
|
||||
map.set(`${i}`, i);
|
||||
}
|
||||
{
|
||||
const persistence = new IndexeddbPersistence('test', yDoc);
|
||||
await persistence.whenSynced;
|
||||
persistence.destroy();
|
||||
}
|
||||
{
|
||||
const persistence = new IndexeddbPersistence('test', yDoc);
|
||||
await persistence.whenSynced;
|
||||
persistence.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function toeverything_create_provider(n = 1e3) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const yDoc = new Y.Doc({
|
||||
guid: 'test',
|
||||
});
|
||||
const provider = createIndexedDBProvider(yDoc);
|
||||
provider.connect();
|
||||
await provider.whenSynced;
|
||||
provider.disconnect();
|
||||
}
|
||||
}
|
||||
async function toeverything_single_persistence(n = 1e5) {
|
||||
const yDoc = new Y.Doc({
|
||||
guid: 'test',
|
||||
});
|
||||
const map = yDoc.getMap();
|
||||
for (let i = 0; i < n; i++) {
|
||||
map.set(`${i}`, i);
|
||||
}
|
||||
const provider = createIndexedDBProvider(yDoc, 'test');
|
||||
provider.connect();
|
||||
await provider.whenSynced;
|
||||
provider.disconnect();
|
||||
provider.connect();
|
||||
await provider.whenSynced;
|
||||
provider.disconnect();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('create many persistence');
|
||||
performance.mark('start');
|
||||
await yjs_create_persistence();
|
||||
performance.mark('end');
|
||||
performance.measure('yjs', 'start', 'end');
|
||||
indexedDB.deleteDatabase('test');
|
||||
performance.mark('start');
|
||||
await toeverything_create_provider();
|
||||
performance.mark('end');
|
||||
performance.measure('toeverything', 'start', 'end');
|
||||
console.log(performance.getEntriesByType('measure'));
|
||||
indexedDB.deleteDatabase('test');
|
||||
performance.clearMarks();
|
||||
performance.clearMeasures();
|
||||
localStorage.clear();
|
||||
|
||||
console.log('single persistence with huge updates');
|
||||
performance.mark('start');
|
||||
await yjs_single_persistence();
|
||||
performance.mark('end');
|
||||
performance.measure('yjs', 'start', 'end');
|
||||
indexedDB.deleteDatabase('test');
|
||||
performance.mark('start');
|
||||
await toeverything_single_persistence();
|
||||
performance.mark('end');
|
||||
performance.measure('toeverything', 'start', 'end');
|
||||
console.log(performance.getEntriesByType('measure'));
|
||||
}
|
||||
|
||||
main().then();
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@toeverything/y-indexeddb",
|
||||
"type": "module",
|
||||
"version": "0.10.0-canary.1",
|
||||
"description": "IndexedDB database adapter for Yjs",
|
||||
"repository": "toeverything/AFFiNE",
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"indexeddb",
|
||||
"yjs",
|
||||
"yjs-adapter"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"default": "./dist/index.umd.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"idb": "^7.1.1",
|
||||
"nanoid": "^5.0.1",
|
||||
"y-provider": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/blocks": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"fake-indexeddb": "^5.0.0",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "3.6.0",
|
||||
"vitest": "0.34.6",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "y-indexeddb",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "packages/common/y-indexeddb/src",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"options": {
|
||||
"outputPath": "packages/common/y-indexeddb/dist"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/vite:build",
|
||||
"options": {
|
||||
"outputPath": "packages/common/y-indexeddb/dist",
|
||||
"watch": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { openDB } from 'idb';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import type { WorkspacePersist } from '../index';
|
||||
import {
|
||||
createIndexedDBProvider,
|
||||
dbVersion,
|
||||
DEFAULT_DB_NAME,
|
||||
downloadBinary,
|
||||
getMilestones,
|
||||
markMilestone,
|
||||
overwriteBinary,
|
||||
revertUpdate,
|
||||
setMergeCount,
|
||||
} from '../index';
|
||||
|
||||
function initEmptyPage(page: Page) {
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
title: new page.Text(''),
|
||||
});
|
||||
const surfaceBlockId = page.addBlock('affine:surface', {}, pageBlockId);
|
||||
const frameBlockId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
const paragraphBlockId = page.addBlock('affine:paragraph', {}, frameBlockId);
|
||||
return {
|
||||
pageBlockId,
|
||||
surfaceBlockId,
|
||||
frameBlockId,
|
||||
paragraphBlockId,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUpdates(id: string): Promise<Uint8Array[]> {
|
||||
const db = await openDB(rootDBName, dbVersion);
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const data = (await store.get(id)) as WorkspacePersist | undefined;
|
||||
assertExists(data, 'data should not be undefined');
|
||||
expect(data.id).toBe(id);
|
||||
return data.updates.map(({ update }) => update);
|
||||
}
|
||||
|
||||
let id: string;
|
||||
let workspace: Workspace;
|
||||
const rootDBName = DEFAULT_DB_NAME;
|
||||
|
||||
const schema = new Schema();
|
||||
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
|
||||
beforeEach(() => {
|
||||
id = nanoid();
|
||||
workspace = new Workspace({
|
||||
id,
|
||||
isSSR: true,
|
||||
schema,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
indexedDB.deleteDatabase('affine-local');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('indexeddb provider', () => {
|
||||
test('connect', async () => {
|
||||
const provider = createIndexedDBProvider(workspace.doc);
|
||||
provider.connect();
|
||||
|
||||
// todo: has a better way to know when data is synced
|
||||
await setTimeout(200);
|
||||
|
||||
const db = await openDB(rootDBName, dbVersion);
|
||||
{
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const data = await store.get(id);
|
||||
expect(data).toEqual({
|
||||
id,
|
||||
updates: [
|
||||
{
|
||||
timestamp: expect.any(Number),
|
||||
update: encodeStateAsUpdate(workspace.doc),
|
||||
},
|
||||
],
|
||||
});
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
}
|
||||
await setTimeout(200);
|
||||
{
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const data = (await store.get(id)) as WorkspacePersist | undefined;
|
||||
assertExists(data);
|
||||
expect(data.id).toBe(id);
|
||||
const testWorkspace = new Workspace({
|
||||
id: 'test',
|
||||
schema,
|
||||
});
|
||||
// data should only contain updates for the root doc
|
||||
data.updates.forEach(({ update }) => {
|
||||
Workspace.Y.applyUpdate(testWorkspace.doc, update);
|
||||
});
|
||||
const subPage = testWorkspace.doc.spaces.get('page0');
|
||||
{
|
||||
assertExists(subPage);
|
||||
await store.get(subPage.guid);
|
||||
const data = (await store.get(subPage.guid)) as
|
||||
| WorkspacePersist
|
||||
| undefined;
|
||||
assertExists(data);
|
||||
await testWorkspace.getPage('page0')?.waitForLoaded();
|
||||
data.updates.forEach(({ update }) => {
|
||||
Workspace.Y.applyUpdate(subPage, update);
|
||||
});
|
||||
}
|
||||
expect(workspace.doc.toJSON()).toEqual(testWorkspace.doc.toJSON());
|
||||
}
|
||||
});
|
||||
|
||||
test('connect and disconnect', async () => {
|
||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||
provider.connect();
|
||||
expect(provider.connected).toBe(true);
|
||||
await setTimeout(200);
|
||||
const snapshot = encodeStateAsUpdate(workspace.doc);
|
||||
provider.disconnect();
|
||||
expect(provider.connected).toBe(false);
|
||||
{
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
}
|
||||
{
|
||||
const updates = await getUpdates(workspace.id);
|
||||
expect(updates.length).toBe(1);
|
||||
expect(updates[0]).toEqual(snapshot);
|
||||
}
|
||||
expect(provider.connected).toBe(false);
|
||||
provider.connect();
|
||||
expect(provider.connected).toBe(true);
|
||||
await setTimeout(200);
|
||||
{
|
||||
const updates = await getUpdates(workspace.id);
|
||||
expect(updates).not.toEqual([]);
|
||||
}
|
||||
expect(provider.connected).toBe(true);
|
||||
provider.disconnect();
|
||||
expect(provider.connected).toBe(false);
|
||||
});
|
||||
|
||||
test('cleanup', async () => {
|
||||
const provider = createIndexedDBProvider(workspace.doc);
|
||||
provider.connect();
|
||||
await setTimeout(200);
|
||||
const db = await openDB(rootDBName, dbVersion);
|
||||
|
||||
{
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const keys = await store.getAllKeys();
|
||||
expect(keys).contain(workspace.id);
|
||||
}
|
||||
|
||||
await provider.cleanup();
|
||||
provider.disconnect();
|
||||
|
||||
{
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const keys = await store.getAllKeys();
|
||||
expect(keys).not.contain(workspace.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('merge', async () => {
|
||||
setMergeCount(5);
|
||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||
provider.connect();
|
||||
{
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
for (let i = 0; i < 99; i++) {
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
}
|
||||
}
|
||||
await setTimeout(200);
|
||||
{
|
||||
const updates = await getUpdates(id);
|
||||
expect(updates.length).lessThanOrEqual(5);
|
||||
}
|
||||
});
|
||||
|
||||
test("data won't be lost", async () => {
|
||||
const doc = new Workspace.Y.Doc();
|
||||
const map = doc.getMap('map');
|
||||
for (let i = 0; i < 100; i++) {
|
||||
map.set(`${i}`, i);
|
||||
}
|
||||
{
|
||||
const provider = createIndexedDBProvider(doc, rootDBName);
|
||||
provider.connect();
|
||||
provider.disconnect();
|
||||
}
|
||||
{
|
||||
const newDoc = new Workspace.Y.Doc();
|
||||
const provider = createIndexedDBProvider(newDoc, rootDBName);
|
||||
provider.connect();
|
||||
provider.disconnect();
|
||||
newDoc.getMap('map').forEach((value, key) => {
|
||||
expect(value).toBe(parseInt(key));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('beforeunload', async () => {
|
||||
const oldAddEventListener = window.addEventListener;
|
||||
window.addEventListener = vi.fn((event: string, fn, options) => {
|
||||
expect(event).toBe('beforeunload');
|
||||
return oldAddEventListener(event, fn, options);
|
||||
});
|
||||
const oldRemoveEventListener = window.removeEventListener;
|
||||
window.removeEventListener = vi.fn((event: string, fn, options) => {
|
||||
expect(event).toBe('beforeunload');
|
||||
return oldRemoveEventListener(event, fn, options);
|
||||
});
|
||||
const doc = new Doc({
|
||||
guid: '1',
|
||||
});
|
||||
const provider = createIndexedDBProvider(doc);
|
||||
const map = doc.getMap('map');
|
||||
map.set('1', 1);
|
||||
provider.connect();
|
||||
|
||||
await setTimeout(200);
|
||||
|
||||
expect(window.addEventListener).toBeCalledTimes(1);
|
||||
expect(window.removeEventListener).toBeCalledTimes(1);
|
||||
|
||||
window.addEventListener = oldAddEventListener;
|
||||
window.removeEventListener = oldRemoveEventListener;
|
||||
});
|
||||
});
|
||||
|
||||
describe('milestone', () => {
|
||||
test('milestone', async () => {
|
||||
const doc = new Doc();
|
||||
const map = doc.getMap('map');
|
||||
const array = doc.getArray('array');
|
||||
map.set('1', 1);
|
||||
array.push([1]);
|
||||
await markMilestone('1', doc, 'test1');
|
||||
const milestones = await getMilestones('1');
|
||||
assertExists(milestones);
|
||||
expect(milestones).toBeDefined();
|
||||
expect(Object.keys(milestones).length).toBe(1);
|
||||
expect(milestones.test1).toBeInstanceOf(Uint8Array);
|
||||
const snapshot = new Doc();
|
||||
applyUpdate(snapshot, milestones.test1);
|
||||
{
|
||||
const map = snapshot.getMap('map');
|
||||
expect(map.get('1')).toBe(1);
|
||||
}
|
||||
map.set('1', 2);
|
||||
{
|
||||
const map = snapshot.getMap('map');
|
||||
expect(map.get('1')).toBe(1);
|
||||
}
|
||||
revertUpdate(doc, milestones.test1, key =>
|
||||
key === 'map' ? 'Map' : 'Array'
|
||||
);
|
||||
{
|
||||
const map = doc.getMap('map');
|
||||
expect(map.get('1')).toBe(1);
|
||||
}
|
||||
|
||||
const fn = vi.fn(() => true);
|
||||
doc.gcFilter = fn;
|
||||
expect(fn).toBeCalledTimes(0);
|
||||
|
||||
for (let i = 0; i < 1e5; i++) {
|
||||
map.set(`${i}`, i + 1);
|
||||
}
|
||||
for (let i = 0; i < 1e5; i++) {
|
||||
map.delete(`${i}`);
|
||||
}
|
||||
for (let i = 0; i < 1e5; i++) {
|
||||
map.set(`${i}`, i - 1);
|
||||
}
|
||||
|
||||
expect(fn).toBeCalled();
|
||||
|
||||
const doc2 = new Doc();
|
||||
applyUpdate(doc2, encodeStateAsUpdate(doc));
|
||||
|
||||
revertUpdate(doc2, milestones.test1, key =>
|
||||
key === 'map' ? 'Map' : 'Array'
|
||||
);
|
||||
{
|
||||
const map = doc2.getMap('map');
|
||||
expect(map.get('1')).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('subDoc', () => {
|
||||
test('basic', async () => {
|
||||
let json1: any, json2: any;
|
||||
{
|
||||
const doc = new Doc({
|
||||
guid: 'test',
|
||||
});
|
||||
const map = doc.getMap();
|
||||
const subDoc = new Doc();
|
||||
subDoc.load();
|
||||
map.set('1', subDoc);
|
||||
map.set('2', 'test');
|
||||
const provider = createIndexedDBProvider(doc);
|
||||
provider.connect();
|
||||
await setTimeout(200);
|
||||
provider.disconnect();
|
||||
json1 = doc.toJSON();
|
||||
}
|
||||
{
|
||||
const doc = new Doc({
|
||||
guid: 'test',
|
||||
});
|
||||
const provider = createIndexedDBProvider(doc);
|
||||
provider.connect();
|
||||
await setTimeout(200);
|
||||
const map = doc.getMap();
|
||||
const subDoc = map.get('1') as Doc;
|
||||
subDoc.load();
|
||||
provider.disconnect();
|
||||
json2 = doc.toJSON();
|
||||
}
|
||||
// the following line compares {} with {}
|
||||
expect(json1['']['1'].toJSON()).toEqual(json2['']['1'].toJSON());
|
||||
expect(json1['']['2']).toEqual(json2['']['2']);
|
||||
});
|
||||
|
||||
test('blocksuite', async () => {
|
||||
const page0 = workspace.createPage({
|
||||
id: 'page0',
|
||||
});
|
||||
await page0.waitForLoaded();
|
||||
const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0);
|
||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||
provider.connect();
|
||||
const page1 = workspace.createPage({
|
||||
id: 'page1',
|
||||
});
|
||||
await page1.waitForLoaded();
|
||||
const { paragraphBlockId: paragraphBlockIdPage2 } = initEmptyPage(page1);
|
||||
await setTimeout(200);
|
||||
provider.disconnect();
|
||||
{
|
||||
const newWorkspace = new Workspace({
|
||||
id,
|
||||
isSSR: true,
|
||||
schema,
|
||||
});
|
||||
const provider = createIndexedDBProvider(newWorkspace.doc, rootDBName);
|
||||
provider.connect();
|
||||
await setTimeout(200);
|
||||
const page0 = newWorkspace.getPage('page0') as Page;
|
||||
await page0.waitForLoaded();
|
||||
await setTimeout(200);
|
||||
{
|
||||
const block = page0.getBlockById(paragraphBlockIdPage1);
|
||||
assertExists(block);
|
||||
}
|
||||
const page1 = newWorkspace.getPage('page1') as Page;
|
||||
await page1.waitForLoaded();
|
||||
await setTimeout(200);
|
||||
{
|
||||
const block = page1.getBlockById(paragraphBlockIdPage2);
|
||||
assertExists(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('utils', () => {
|
||||
test('download binary', async () => {
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
initEmptyPage(page);
|
||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||
provider.connect();
|
||||
await setTimeout(200);
|
||||
provider.disconnect();
|
||||
const update = (await downloadBinary(
|
||||
workspace.id,
|
||||
rootDBName
|
||||
)) as Uint8Array;
|
||||
expect(update).toBeInstanceOf(Uint8Array);
|
||||
const newWorkspace = new Workspace({
|
||||
id,
|
||||
isSSR: true,
|
||||
schema,
|
||||
});
|
||||
applyUpdate(newWorkspace.doc, update);
|
||||
await setTimeout();
|
||||
expect(workspace.doc.toJSON()['meta']).toEqual(
|
||||
newWorkspace.doc.toJSON()['meta']
|
||||
);
|
||||
expect(Object.keys(workspace.doc.toJSON()['spaces'])).toEqual(
|
||||
Object.keys(newWorkspace.doc.toJSON()['spaces'])
|
||||
);
|
||||
});
|
||||
|
||||
test('overwrite binary', async () => {
|
||||
const doc = new Doc();
|
||||
const map = doc.getMap();
|
||||
map.set('1', 1);
|
||||
await overwriteBinary('test', new Uint8Array(encodeStateAsUpdate(doc)));
|
||||
{
|
||||
const binary = await downloadBinary('test');
|
||||
expect(binary).toEqual(new Uint8Array(encodeStateAsUpdate(doc)));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { openDB } from 'idb';
|
||||
import {
|
||||
applyUpdate,
|
||||
Doc,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateVector,
|
||||
UndoManager,
|
||||
} from 'yjs';
|
||||
|
||||
import type { BlockSuiteBinaryDB, WorkspaceMilestone } from './shared';
|
||||
import { dbVersion, DEFAULT_DB_NAME, upgradeDB } from './shared';
|
||||
|
||||
const snapshotOrigin = 'snapshot-origin';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
const saveAlert = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
return (event.returnValue =
|
||||
'Data is not saved. Are you sure you want to leave?');
|
||||
};
|
||||
|
||||
export const writeOperation = async (op: Promise<unknown>) => {
|
||||
window.addEventListener('beforeunload', saveAlert, {
|
||||
capture: true,
|
||||
});
|
||||
await op;
|
||||
window.removeEventListener('beforeunload', saveAlert, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
|
||||
export function revertUpdate(
|
||||
doc: Doc,
|
||||
snapshotUpdate: Uint8Array,
|
||||
getMetadata: (key: string) => 'Text' | 'Map' | 'Array'
|
||||
) {
|
||||
const snapshotDoc = new Doc();
|
||||
applyUpdate(snapshotDoc, snapshotUpdate, snapshotOrigin);
|
||||
|
||||
const currentStateVector = encodeStateVector(doc);
|
||||
const snapshotStateVector = encodeStateVector(snapshotDoc);
|
||||
|
||||
const changesSinceSnapshotUpdate = encodeStateAsUpdate(
|
||||
doc,
|
||||
snapshotStateVector
|
||||
);
|
||||
const undoManager = new UndoManager(
|
||||
[...snapshotDoc.share.keys()].map(key => {
|
||||
const type = getMetadata(key);
|
||||
if (type === 'Text') {
|
||||
return snapshotDoc.getText(key);
|
||||
} else if (type === 'Map') {
|
||||
return snapshotDoc.getMap(key);
|
||||
} else if (type === 'Array') {
|
||||
return snapshotDoc.getArray(key);
|
||||
}
|
||||
throw new Error('Unknown type');
|
||||
}),
|
||||
{
|
||||
trackedOrigins: new Set([snapshotOrigin]),
|
||||
}
|
||||
);
|
||||
applyUpdate(snapshotDoc, changesSinceSnapshotUpdate, snapshotOrigin);
|
||||
undoManager.undo();
|
||||
const revertChangesSinceSnapshotUpdate = encodeStateAsUpdate(
|
||||
snapshotDoc,
|
||||
currentStateVector
|
||||
);
|
||||
applyUpdate(doc, revertChangesSinceSnapshotUpdate, snapshotOrigin);
|
||||
}
|
||||
|
||||
export class EarlyDisconnectError extends Error {
|
||||
constructor() {
|
||||
super('Early disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
export class CleanupWhenConnectingError extends Error {
|
||||
constructor() {
|
||||
super('Cleanup when connecting');
|
||||
}
|
||||
}
|
||||
|
||||
export const markMilestone = async (
|
||||
id: string,
|
||||
doc: Doc,
|
||||
name: string,
|
||||
dbName = DEFAULT_DB_NAME
|
||||
): Promise<void> => {
|
||||
const dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
const db = await dbPromise;
|
||||
const store = db
|
||||
.transaction('milestone', 'readwrite')
|
||||
.objectStore('milestone');
|
||||
const milestone = await store.get('id');
|
||||
const binary = encodeStateAsUpdate(doc);
|
||||
if (!milestone) {
|
||||
await store.put({
|
||||
id,
|
||||
milestone: {
|
||||
[name]: binary,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
milestone.milestone[name] = binary;
|
||||
await store.put(milestone);
|
||||
}
|
||||
};
|
||||
|
||||
export const getMilestones = async (
|
||||
id: string,
|
||||
dbName: string = DEFAULT_DB_NAME
|
||||
): Promise<null | WorkspaceMilestone['milestone']> => {
|
||||
const dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
const db = await dbPromise;
|
||||
const store = db
|
||||
.transaction('milestone', 'readonly')
|
||||
.objectStore('milestone');
|
||||
const milestone = await store.get(id);
|
||||
if (!milestone) {
|
||||
return null;
|
||||
}
|
||||
return milestone.milestone;
|
||||
};
|
||||
|
||||
export * from './provider';
|
||||
export * from './shared';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,162 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
import {
|
||||
createLazyProvider,
|
||||
type DocDataSource,
|
||||
writeOperation,
|
||||
} from 'y-provider';
|
||||
import type { Doc } from 'yjs';
|
||||
import { diffUpdate, encodeStateVectorFromUpdate } from 'yjs';
|
||||
|
||||
import {
|
||||
type BlockSuiteBinaryDB,
|
||||
dbVersion,
|
||||
DEFAULT_DB_NAME,
|
||||
type IndexedDBProvider,
|
||||
type UpdateMessage,
|
||||
upgradeDB,
|
||||
} from './shared';
|
||||
import { mergeUpdates } from './utils';
|
||||
|
||||
let mergeCount = 500;
|
||||
|
||||
export function setMergeCount(count: number) {
|
||||
mergeCount = count;
|
||||
}
|
||||
|
||||
export const createIndexedDBDatasource = ({
|
||||
dbName = DEFAULT_DB_NAME,
|
||||
mergeCount,
|
||||
}: {
|
||||
dbName?: string;
|
||||
mergeCount?: number;
|
||||
}) => {
|
||||
let dbPromise: Promise<IDBPDatabase<BlockSuiteBinaryDB>> | null = null;
|
||||
const getDb = async () => {
|
||||
if (dbPromise === null) {
|
||||
dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
};
|
||||
|
||||
const adapter = {
|
||||
queryDocState: async (guid, options) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const data = await store.get(guid);
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { updates } = data;
|
||||
const update = mergeUpdates(updates.map(({ update }) => update));
|
||||
|
||||
const missing = options?.stateVector
|
||||
? diffUpdate(update, options?.stateVector)
|
||||
: update;
|
||||
|
||||
return { missing, state: encodeStateVectorFromUpdate(update) };
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('The database connection is closing.')) {
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
sendDocUpdate: async (guid, update) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const store = db
|
||||
.transaction('workspace', 'readwrite')
|
||||
.objectStore('workspace');
|
||||
|
||||
// TODO: maybe we do not need to get data every time
|
||||
const { updates } = (await store.get(guid)) ?? { updates: [] };
|
||||
let rows: UpdateMessage[] = [
|
||||
...updates,
|
||||
{ timestamp: Date.now(), update },
|
||||
];
|
||||
if (mergeCount && rows.length >= mergeCount) {
|
||||
const merged = mergeUpdates(rows.map(({ update }) => update));
|
||||
rows = [{ timestamp: Date.now(), update: merged }];
|
||||
}
|
||||
await writeOperation(
|
||||
store.put({
|
||||
id: guid,
|
||||
updates: rows,
|
||||
})
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('The database connection is closing.')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
} satisfies DocDataSource;
|
||||
|
||||
return {
|
||||
...adapter,
|
||||
disconnect: () => {
|
||||
getDb()
|
||||
.then(db => db.close())
|
||||
.then(() => {
|
||||
dbPromise = null;
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
cleanup: async () => {
|
||||
const db = await getDb();
|
||||
await db.clear('workspace');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* We use `doc.guid` as the unique key, please make sure it not changes.
|
||||
*/
|
||||
export const createIndexedDBProvider = (
|
||||
doc: Doc,
|
||||
dbName: string = DEFAULT_DB_NAME
|
||||
): IndexedDBProvider => {
|
||||
const datasource = createIndexedDBDatasource({ dbName, mergeCount });
|
||||
let provider: ReturnType<typeof createLazyProvider> | null = null;
|
||||
|
||||
const apis = {
|
||||
get status() {
|
||||
assertExists(provider);
|
||||
return provider.status;
|
||||
},
|
||||
subscribeStatusChange(onStatusChange) {
|
||||
assertExists(provider);
|
||||
return provider.subscribeStatusChange(onStatusChange);
|
||||
},
|
||||
connect: () => {
|
||||
if (apis.connected) {
|
||||
apis.disconnect();
|
||||
}
|
||||
provider = createLazyProvider(doc, datasource, { origin: 'idb' });
|
||||
provider.connect();
|
||||
},
|
||||
disconnect: () => {
|
||||
datasource?.disconnect();
|
||||
provider?.disconnect();
|
||||
provider = null;
|
||||
},
|
||||
cleanup: async () => {
|
||||
await datasource?.cleanup();
|
||||
},
|
||||
get connected() {
|
||||
return provider?.connected || false;
|
||||
},
|
||||
datasource,
|
||||
} satisfies IndexedDBProvider;
|
||||
|
||||
return apis;
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DBSchema, IDBPDatabase } from 'idb';
|
||||
import type { DataSourceAdapter } from 'y-provider';
|
||||
|
||||
export const dbVersion = 1;
|
||||
export const DEFAULT_DB_NAME = 'affine-local';
|
||||
|
||||
export function upgradeDB(db: IDBPDatabase<BlockSuiteBinaryDB>) {
|
||||
db.createObjectStore('workspace', { keyPath: 'id' });
|
||||
db.createObjectStore('milestone', { keyPath: 'id' });
|
||||
}
|
||||
|
||||
export interface IndexedDBProvider extends DataSourceAdapter {
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
cleanup: () => Promise<void>;
|
||||
readonly connected: boolean;
|
||||
}
|
||||
|
||||
export type UpdateMessage = {
|
||||
timestamp: number;
|
||||
update: Uint8Array;
|
||||
};
|
||||
|
||||
export type WorkspacePersist = {
|
||||
id: string;
|
||||
updates: UpdateMessage[];
|
||||
};
|
||||
|
||||
export type WorkspaceMilestone = {
|
||||
id: string;
|
||||
milestone: Record<string, Uint8Array>;
|
||||
};
|
||||
|
||||
export interface BlockSuiteBinaryDB extends DBSchema {
|
||||
workspace: {
|
||||
key: string;
|
||||
value: WorkspacePersist;
|
||||
};
|
||||
milestone: {
|
||||
key: string;
|
||||
value: WorkspaceMilestone;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OldYjsDB extends DBSchema {
|
||||
updates: {
|
||||
key: number;
|
||||
value: Uint8Array;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import type { BlockSuiteBinaryDB, OldYjsDB, UpdateMessage } from './shared';
|
||||
import { dbVersion, DEFAULT_DB_NAME, upgradeDB } from './shared';
|
||||
|
||||
let allDb: IDBDatabaseInfo[];
|
||||
|
||||
export function mergeUpdates(updates: Uint8Array[]) {
|
||||
const doc = new Doc();
|
||||
updates.forEach(update => {
|
||||
applyUpdate(doc, update);
|
||||
});
|
||||
return encodeStateAsUpdate(doc);
|
||||
}
|
||||
|
||||
async function databaseExists(name: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const req = indexedDB.open(name);
|
||||
let existed = true;
|
||||
req.onsuccess = function () {
|
||||
req.result.close();
|
||||
if (!existed) {
|
||||
indexedDB.deleteDatabase(name);
|
||||
}
|
||||
resolve(existed);
|
||||
};
|
||||
req.onupgradeneeded = function () {
|
||||
existed = false;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* try to migrate the old database to the new database
|
||||
* this function will be removed in the future
|
||||
* since we don't need to support the old database
|
||||
*/
|
||||
export async function tryMigrate(
|
||||
db: IDBPDatabase<BlockSuiteBinaryDB>,
|
||||
id: string,
|
||||
dbName = DEFAULT_DB_NAME
|
||||
) {
|
||||
do {
|
||||
if (!allDb || localStorage.getItem(`${dbName}-migration`) !== 'true') {
|
||||
try {
|
||||
allDb = await indexedDB.databases();
|
||||
} catch {
|
||||
// in firefox, `indexedDB.databases` is not existed
|
||||
if (await databaseExists(id)) {
|
||||
await openDB<IDBPDatabase<OldYjsDB>>(id, 1).then(async oldDB => {
|
||||
if (!oldDB.objectStoreNames.contains('updates')) {
|
||||
return;
|
||||
}
|
||||
const t = oldDB
|
||||
.transaction('updates', 'readonly')
|
||||
.objectStore('updates');
|
||||
const updates = await t.getAll();
|
||||
if (
|
||||
!Array.isArray(updates) ||
|
||||
!updates.every(update => update instanceof Uint8Array)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const update = mergeUpdates(updates);
|
||||
const workspaceTransaction = db
|
||||
.transaction('workspace', 'readwrite')
|
||||
.objectStore('workspace');
|
||||
const data = await workspaceTransaction.get(id);
|
||||
if (!data) {
|
||||
console.log('upgrading the database');
|
||||
await workspaceTransaction.put({
|
||||
id,
|
||||
updates: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
update,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
// run the migration
|
||||
await Promise.all(
|
||||
allDb &&
|
||||
allDb.map(meta => {
|
||||
if (meta.name && meta.version === 1) {
|
||||
const name = meta.name;
|
||||
const version = meta.version;
|
||||
return openDB<IDBPDatabase<OldYjsDB>>(name, version).then(
|
||||
async oldDB => {
|
||||
if (!oldDB.objectStoreNames.contains('updates')) {
|
||||
return;
|
||||
}
|
||||
const t = oldDB
|
||||
.transaction('updates', 'readonly')
|
||||
.objectStore('updates');
|
||||
const updates = await t.getAll();
|
||||
if (
|
||||
!Array.isArray(updates) ||
|
||||
!updates.every(update => update instanceof Uint8Array)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const update = mergeUpdates(updates);
|
||||
const workspaceTransaction = db
|
||||
.transaction('workspace', 'readwrite')
|
||||
.objectStore('workspace');
|
||||
const data = await workspaceTransaction.get(name);
|
||||
if (!data) {
|
||||
console.log('upgrading the database');
|
||||
await workspaceTransaction.put({
|
||||
id: name,
|
||||
updates: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
update,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return void 0;
|
||||
})
|
||||
);
|
||||
localStorage.setItem(`${dbName}-migration`, 'true');
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
} while (false);
|
||||
}
|
||||
|
||||
export async function downloadBinary(
|
||||
guid: string,
|
||||
dbName = DEFAULT_DB_NAME
|
||||
): Promise<UpdateMessage['update'] | false> {
|
||||
const dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
const db = await dbPromise;
|
||||
const t = db.transaction('workspace', 'readonly').objectStore('workspace');
|
||||
const doc = await t.get(guid);
|
||||
if (!doc) {
|
||||
return false;
|
||||
} else {
|
||||
return mergeUpdates(doc.updates.map(({ update }) => update));
|
||||
}
|
||||
}
|
||||
|
||||
export async function overwriteBinary(
|
||||
guid: string,
|
||||
update: UpdateMessage['update'],
|
||||
dbName = DEFAULT_DB_NAME
|
||||
) {
|
||||
const dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
const db = await dbPromise;
|
||||
const t = db.transaction('workspace', 'readwrite').objectStore('workspace');
|
||||
await t.put({
|
||||
id: guid,
|
||||
updates: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
update,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function pushBinary(
|
||||
guid: string,
|
||||
update: UpdateMessage['update'],
|
||||
dbName = DEFAULT_DB_NAME
|
||||
) {
|
||||
const dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
const db = await dbPromise;
|
||||
const t = db.transaction('workspace', 'readwrite').objectStore('workspace');
|
||||
const doc = await t.get(guid);
|
||||
if (!doc) {
|
||||
await t.put({
|
||||
id: guid,
|
||||
updates: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
update,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
doc.updates.push({
|
||||
timestamp: Date.now(),
|
||||
update,
|
||||
});
|
||||
await t.put(doc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "../y-provider"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
fileName: 'index',
|
||||
name: 'ToEverythingIndexedDBProvider',
|
||||
formats: ['es', 'cjs', 'umd'],
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
globals: {
|
||||
idb: 'idb',
|
||||
yjs: 'yjs',
|
||||
'y-provider': 'yProvider',
|
||||
},
|
||||
},
|
||||
external: ['idb', 'yjs', 'y-provider'],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: resolve(__dirname, 'src'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
# A set of provider utilities for Yjs
|
||||
|
||||
## createLazyProvider
|
||||
|
||||
A factory function to create a lazy provider. It will not download the document from the provider until the first time a document is loaded at the parent doc.
|
||||
|
||||
To use it, first define a `DatasourceDocAdapter`.
|
||||
Then, create a `LazyProvider` with `createLazyProvider(rootDoc, datasource)`.
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "y-provider",
|
||||
"type": "module",
|
||||
"version": "0.10.0-canary.0",
|
||||
"description": "Yjs provider protocol for multi document support",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs",
|
||||
"default": "./dist/index.umd.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/store": "0.0.0-20231018100009-361737d3-nightly",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "3.6.0",
|
||||
"vitest": "0.34.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
|
||||
import type { DocDataSource } from '../data-source';
|
||||
import { createLazyProvider } from '../lazy-provider';
|
||||
import { getDoc } from '../utils';
|
||||
|
||||
const createMemoryDatasource = (rootDoc: Doc) => {
|
||||
const selfUpdateOrigin = Symbol('self-origin');
|
||||
const listeners = new Set<(guid: string, update: Uint8Array) => void>();
|
||||
|
||||
function trackDoc(doc: Doc) {
|
||||
doc.on('update', (update, origin) => {
|
||||
if (origin === selfUpdateOrigin) {
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener(doc.guid, update);
|
||||
}
|
||||
});
|
||||
|
||||
doc.on('subdocs', () => {
|
||||
for (const subdoc of rootDoc.subdocs) {
|
||||
trackDoc(subdoc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackDoc(rootDoc);
|
||||
|
||||
const adapter = {
|
||||
queryDocState: async (guid, options) => {
|
||||
const subdoc = getDoc(rootDoc, guid);
|
||||
if (!subdoc) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
missing: encodeStateAsUpdate(subdoc, options?.stateVector),
|
||||
state: encodeStateVector(subdoc),
|
||||
};
|
||||
},
|
||||
sendDocUpdate: async (guid, update) => {
|
||||
const subdoc = getDoc(rootDoc, guid);
|
||||
if (!subdoc) {
|
||||
return;
|
||||
}
|
||||
applyUpdate(subdoc, update, selfUpdateOrigin);
|
||||
},
|
||||
onDocUpdate: callback => {
|
||||
listeners.add(callback);
|
||||
return () => {
|
||||
listeners.delete(callback);
|
||||
};
|
||||
},
|
||||
} satisfies DocDataSource;
|
||||
return {
|
||||
rootDoc, // expose rootDoc for testing
|
||||
...adapter,
|
||||
};
|
||||
};
|
||||
|
||||
describe('y-provider', () => {
|
||||
test('should sync a subdoc if it is loaded after connect', async () => {
|
||||
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
|
||||
const datasource = createMemoryDatasource(remoteRootDoc);
|
||||
|
||||
const remotesubdoc = new Doc();
|
||||
remotesubdoc.getText('text').insert(0, 'test-subdoc-value');
|
||||
// populate remote doc with simple data
|
||||
remoteRootDoc.getMap('map').set('test-0', 'test-0-value');
|
||||
remoteRootDoc.getMap('map').set('subdoc', remotesubdoc);
|
||||
|
||||
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
|
||||
const provider = createLazyProvider(rootDoc, datasource);
|
||||
|
||||
provider.connect();
|
||||
|
||||
await setTimeout(); // wait for the provider to sync
|
||||
|
||||
const subdoc = rootDoc.getMap('map').get('subdoc') as Doc;
|
||||
|
||||
expect(rootDoc.getMap('map').get('test-0')).toBe('test-0-value');
|
||||
expect(subdoc.getText('text').toJSON()).toBe('');
|
||||
|
||||
// onload, the provider should sync the subdoc
|
||||
subdoc.load();
|
||||
await setTimeout();
|
||||
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
|
||||
|
||||
remotesubdoc.getText('text').insert(0, 'prefix-');
|
||||
await setTimeout();
|
||||
expect(subdoc.getText('text').toJSON()).toBe('prefix-test-subdoc-value');
|
||||
|
||||
// disconnect then reconnect
|
||||
provider.disconnect();
|
||||
remotesubdoc.getText('text').delete(0, 'prefix-'.length);
|
||||
await setTimeout();
|
||||
expect(subdoc.getText('text').toJSON()).toBe('prefix-test-subdoc-value');
|
||||
|
||||
provider.connect();
|
||||
await setTimeout();
|
||||
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
|
||||
});
|
||||
|
||||
test('should sync a shouldLoad=true subdoc on connect', async () => {
|
||||
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
|
||||
const datasource = createMemoryDatasource(remoteRootDoc);
|
||||
|
||||
const remotesubdoc = new Doc();
|
||||
remotesubdoc.getText('text').insert(0, 'test-subdoc-value');
|
||||
|
||||
// populate remote doc with simple data
|
||||
remoteRootDoc.getMap('map').set('test-0', 'test-0-value');
|
||||
remoteRootDoc.getMap('map').set('subdoc', remotesubdoc);
|
||||
|
||||
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
|
||||
applyUpdate(rootDoc, encodeStateAsUpdate(remoteRootDoc)); // sync rootDoc with remoteRootDoc
|
||||
|
||||
const subdoc = rootDoc.getMap('map').get('subdoc') as Doc;
|
||||
expect(subdoc.getText('text').toJSON()).toBe('');
|
||||
|
||||
subdoc.load();
|
||||
const provider = createLazyProvider(rootDoc, datasource);
|
||||
|
||||
provider.connect();
|
||||
await setTimeout(); // wait for the provider to sync
|
||||
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
|
||||
});
|
||||
|
||||
test('should send existing local update to remote on connect', async () => {
|
||||
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
|
||||
const datasource = createMemoryDatasource(remoteRootDoc);
|
||||
|
||||
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
|
||||
applyUpdate(rootDoc, encodeStateAsUpdate(remoteRootDoc)); // sync rootDoc with remoteRootDoc
|
||||
|
||||
rootDoc.getText('text').insert(0, 'test-value');
|
||||
const provider = createLazyProvider(rootDoc, datasource);
|
||||
provider.connect();
|
||||
await setTimeout(); // wait for the provider to sync
|
||||
|
||||
expect(remoteRootDoc.getText('text').toJSON()).toBe('test-value');
|
||||
});
|
||||
|
||||
test('should send local update to remote for subdoc after connect', async () => {
|
||||
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
|
||||
const datasource = createMemoryDatasource(remoteRootDoc);
|
||||
|
||||
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
|
||||
const provider = createLazyProvider(rootDoc, datasource);
|
||||
|
||||
provider.connect();
|
||||
|
||||
await setTimeout(); // wait for the provider to sync
|
||||
|
||||
const subdoc = new Doc();
|
||||
rootDoc.getMap('map').set('subdoc', subdoc);
|
||||
subdoc.getText('text').insert(0, 'test-subdoc-value');
|
||||
|
||||
await setTimeout(); // wait for the provider to sync
|
||||
|
||||
const remoteSubdoc = remoteRootDoc.getMap('map').get('subdoc') as Doc;
|
||||
expect(remoteSubdoc.getText('text').toJSON()).toBe('test-subdoc-value');
|
||||
});
|
||||
|
||||
test('should not send local update to remote for subdoc after disconnect', async () => {
|
||||
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
|
||||
const datasource = createMemoryDatasource(remoteRootDoc);
|
||||
|
||||
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
|
||||
const provider = createLazyProvider(rootDoc, datasource);
|
||||
|
||||
provider.connect();
|
||||
|
||||
await setTimeout(); // wait for the provider to sync
|
||||
|
||||
const subdoc = new Doc();
|
||||
rootDoc.getMap('map').set('subdoc', subdoc);
|
||||
|
||||
await setTimeout(); // wait for the provider to sync
|
||||
|
||||
const remoteSubdoc = remoteRootDoc.getMap('map').get('subdoc') as Doc;
|
||||
expect(remoteSubdoc.getText('text').toJSON()).toBe('');
|
||||
|
||||
provider.disconnect();
|
||||
subdoc.getText('text').insert(0, 'test-subdoc-value');
|
||||
await setTimeout();
|
||||
expect(remoteSubdoc.getText('text').toJSON()).toBe('');
|
||||
|
||||
expect(provider.connected).toBe(false);
|
||||
});
|
||||
|
||||
test('should not send remote update back', async () => {
|
||||
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
|
||||
const datasource = createMemoryDatasource(remoteRootDoc);
|
||||
const spy = vi.spyOn(datasource, 'sendDocUpdate');
|
||||
|
||||
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
|
||||
const provider = createLazyProvider(rootDoc, datasource);
|
||||
|
||||
provider.connect();
|
||||
|
||||
remoteRootDoc.getText('text').insert(0, 'test-value');
|
||||
|
||||
expect(spy).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('only sync', async () => {
|
||||
const remoteRootDoc = new Doc(); // this is the remote doc lives in remote
|
||||
const datasource = createMemoryDatasource(remoteRootDoc);
|
||||
remoteRootDoc.getText().insert(0, 'hello, world!');
|
||||
|
||||
const rootDoc = new Doc({ guid: remoteRootDoc.guid }); // this is the doc that we want to sync
|
||||
const provider = createLazyProvider(rootDoc, datasource);
|
||||
|
||||
await provider.sync(true);
|
||||
expect(rootDoc.getText().toJSON()).toBe('hello, world!');
|
||||
|
||||
const remotesubdoc = new Doc();
|
||||
remotesubdoc.getText('text').insert(0, 'test-subdoc-value');
|
||||
remoteRootDoc.getMap('map').set('subdoc', remotesubdoc);
|
||||
expect(rootDoc.subdocs.size).toBe(0);
|
||||
|
||||
await provider.sync(true);
|
||||
expect(rootDoc.subdocs.size).toBe(1);
|
||||
const subdoc = rootDoc.getMap('map').get('subdoc') as Doc;
|
||||
expect(subdoc.getText('text').toJSON()).toBe('');
|
||||
await provider.sync(true);
|
||||
expect(subdoc.getText('text').toJSON()).toBe('');
|
||||
await provider.sync(false);
|
||||
expect(subdoc.getText('text').toJSON()).toBe('test-subdoc-value');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Doc as YDoc } from 'yjs';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import type { DocState } from './types';
|
||||
|
||||
export interface DocDataSource {
|
||||
/**
|
||||
* request diff update from other clients
|
||||
*/
|
||||
queryDocState: (
|
||||
guid: string,
|
||||
options?: {
|
||||
stateVector?: Uint8Array;
|
||||
targetClientId?: number;
|
||||
}
|
||||
) => Promise<DocState | false>;
|
||||
|
||||
/**
|
||||
* send update to the datasource
|
||||
*/
|
||||
sendDocUpdate: (guid: string, update: Uint8Array) => Promise<void>;
|
||||
|
||||
/**
|
||||
* listen to update from the datasource. Returns a function to unsubscribe.
|
||||
* this is optional because some datasource might not support it
|
||||
*/
|
||||
onDocUpdate?(
|
||||
callback: (guid: string, update: Uint8Array) => void
|
||||
): () => void;
|
||||
}
|
||||
|
||||
export async function syncDocFromDataSource(
|
||||
rootDoc: YDoc,
|
||||
datasource: DocDataSource
|
||||
) {
|
||||
const downloadDocStateRecursively = async (doc: YDoc) => {
|
||||
const docState = await datasource.queryDocState(doc.guid);
|
||||
if (docState) {
|
||||
applyUpdate(doc, docState.missing, 'sync-doc-from-datasource');
|
||||
}
|
||||
await Promise.all(
|
||||
[...doc.subdocs].map(async subdoc => {
|
||||
await downloadDocStateRecursively(subdoc);
|
||||
})
|
||||
);
|
||||
};
|
||||
await downloadDocStateRecursively(rootDoc);
|
||||
}
|
||||
|
||||
export async function syncDataSourceFromDoc(
|
||||
rootDoc: YDoc,
|
||||
datasource: DocDataSource
|
||||
) {
|
||||
const uploadDocStateRecursively = async (doc: YDoc) => {
|
||||
await datasource.sendDocUpdate(doc.guid, encodeStateAsUpdate(doc));
|
||||
await Promise.all(
|
||||
[...doc.subdocs].map(async subdoc => {
|
||||
await uploadDocStateRecursively(subdoc);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
await uploadDocStateRecursively(rootDoc);
|
||||
}
|
||||
|
||||
/**
|
||||
* query the datasource from source, and save the latest update to target
|
||||
*
|
||||
* @example
|
||||
* bindDataSource(socketIO, indexedDB)
|
||||
* bindDataSource(socketIO, sqlite)
|
||||
*/
|
||||
export async function syncDataSource(
|
||||
listDocGuids: () => string[],
|
||||
remoteDataSource: DocDataSource,
|
||||
localDataSource: DocDataSource
|
||||
) {
|
||||
const guids = listDocGuids();
|
||||
await Promise.all(
|
||||
guids.map(guid => {
|
||||
return localDataSource.queryDocState(guid).then(async docState => {
|
||||
const remoteDocState = await (async () => {
|
||||
if (docState) {
|
||||
return remoteDataSource.queryDocState(guid, {
|
||||
stateVector: docState.state,
|
||||
});
|
||||
} else {
|
||||
return remoteDataSource.queryDocState(guid);
|
||||
}
|
||||
})();
|
||||
if (remoteDocState) {
|
||||
const missing = remoteDocState.missing;
|
||||
if (missing.length === 2 && missing[0] === 0 && missing[1] === 0) {
|
||||
// empty update
|
||||
return;
|
||||
}
|
||||
await localDataSource.sendDocUpdate(guid, remoteDocState.missing);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './data-source';
|
||||
export * from './lazy-provider';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,380 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
applyUpdate,
|
||||
type Doc,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateVector,
|
||||
} from 'yjs';
|
||||
|
||||
import type { DocDataSource } from './data-source';
|
||||
import type { DataSourceAdapter } from './types';
|
||||
import type { Status } from './types';
|
||||
|
||||
function getDoc(doc: Doc, guid: string): Doc | undefined {
|
||||
if (doc.guid === guid) {
|
||||
return doc;
|
||||
}
|
||||
for (const subdoc of doc.subdocs) {
|
||||
const found = getDoc(subdoc, guid);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface LazyProviderOptions {
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export type DocProvider = {
|
||||
// backport from `@blocksuite/store`
|
||||
passive: true;
|
||||
|
||||
sync(onlyRootDoc?: boolean): Promise<void>;
|
||||
|
||||
get connected(): boolean;
|
||||
connect(): void;
|
||||
disconnect(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a lazy provider that connects to a datasource and synchronizes a root document.
|
||||
*/
|
||||
export const createLazyProvider = (
|
||||
rootDoc: Doc,
|
||||
datasource: DocDataSource,
|
||||
options: LazyProviderOptions = {}
|
||||
): DocProvider & DataSourceAdapter => {
|
||||
let connected = false;
|
||||
const pendingMap = new Map<string, Uint8Array[]>(); // guid -> pending-updates
|
||||
const disposableMap = new Map<string, Set<() => void>>();
|
||||
const connectedDocs = new Set<string>();
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
const { origin = 'lazy-provider' } = options;
|
||||
|
||||
// todo: should we use a real state machine here like `xstate`?
|
||||
let currentStatus: Status = {
|
||||
type: 'idle',
|
||||
};
|
||||
let syncingStack = 0;
|
||||
const callbackSet = new Set<() => void>();
|
||||
const changeStatus = (newStatus: Status) => {
|
||||
// simulate a stack, each syncing and synced should be paired
|
||||
if (newStatus.type === 'syncing') {
|
||||
syncingStack++;
|
||||
} else if (newStatus.type === 'synced' || newStatus.type === 'error') {
|
||||
syncingStack--;
|
||||
}
|
||||
|
||||
if (syncingStack < 0) {
|
||||
console.error(
|
||||
'syncingStatus < 0, this should not happen',
|
||||
options.origin
|
||||
);
|
||||
}
|
||||
|
||||
if (syncingStack === 0) {
|
||||
currentStatus = newStatus;
|
||||
}
|
||||
if (newStatus.type !== 'synced') {
|
||||
currentStatus = newStatus;
|
||||
}
|
||||
if (syncingStack === 0) {
|
||||
if (!connected) {
|
||||
currentStatus = {
|
||||
type: 'idle',
|
||||
};
|
||||
} else {
|
||||
currentStatus = {
|
||||
type: 'synced',
|
||||
};
|
||||
}
|
||||
}
|
||||
callbackSet.forEach(cb => cb());
|
||||
};
|
||||
|
||||
async function syncDoc(doc: Doc) {
|
||||
const guid = doc.guid;
|
||||
{
|
||||
// backport from `@blocksuite/store`
|
||||
const prefixId = guid.startsWith('space:') ? guid.slice(6) : guid;
|
||||
const possible1 = `${rootDoc.guid}:space:${prefixId}`;
|
||||
const possible2 = `space:${prefixId}`;
|
||||
const update1 = await datasource.queryDocState(possible1);
|
||||
const update2 = await datasource.queryDocState(possible2);
|
||||
let hasUpdate = false;
|
||||
if (
|
||||
update1 &&
|
||||
update1.missing.length !== 2 &&
|
||||
update1.missing[0] !== 0 &&
|
||||
update1.missing[1] !== 0
|
||||
) {
|
||||
applyUpdate(doc, update1.missing, origin);
|
||||
hasUpdate = true;
|
||||
}
|
||||
if (
|
||||
update2 &&
|
||||
update2.missing.length !== 2 &&
|
||||
update2.missing[0] !== 0 &&
|
||||
update2.missing[1] !== 0
|
||||
) {
|
||||
applyUpdate(doc, update2.missing, origin);
|
||||
hasUpdate = true;
|
||||
}
|
||||
if (hasUpdate) {
|
||||
await datasource.sendDocUpdate(
|
||||
guid,
|
||||
encodeStateAsUpdate(
|
||||
doc,
|
||||
update1 ? update1.state : update2 ? update2.state : undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
changeStatus({
|
||||
type: 'syncing',
|
||||
});
|
||||
const remoteUpdate = await datasource
|
||||
.queryDocState(guid, {
|
||||
stateVector: encodeStateVector(doc),
|
||||
})
|
||||
.then(remoteUpdate => {
|
||||
changeStatus({
|
||||
type: 'synced',
|
||||
});
|
||||
return remoteUpdate;
|
||||
})
|
||||
.catch(error => {
|
||||
changeStatus({
|
||||
type: 'error',
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
});
|
||||
|
||||
pendingMap.set(guid, []);
|
||||
|
||||
if (remoteUpdate) {
|
||||
applyUpdate(doc, remoteUpdate.missing, origin);
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// perf: optimize me
|
||||
// it is possible the doc is only in memory but not yet in the datasource
|
||||
// we need to send the whole update to the datasource
|
||||
await datasource.sendDocUpdate(
|
||||
guid,
|
||||
encodeStateAsUpdate(doc, remoteUpdate ? remoteUpdate.state : undefined)
|
||||
);
|
||||
|
||||
doc.emit('sync', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners for a Yjs document.
|
||||
* @param doc - The Yjs document to set up listeners for.
|
||||
*/
|
||||
function setupDocListener(doc: Doc) {
|
||||
const disposables = new Set<() => void>();
|
||||
disposableMap.set(doc.guid, disposables);
|
||||
const updateHandler = async (update: Uint8Array, updateOrigin: unknown) => {
|
||||
if (origin === updateOrigin) {
|
||||
return;
|
||||
}
|
||||
changeStatus({
|
||||
type: 'syncing',
|
||||
});
|
||||
datasource
|
||||
.sendDocUpdate(doc.guid, update)
|
||||
.then(() => {
|
||||
changeStatus({
|
||||
type: 'synced',
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
changeStatus({
|
||||
type: 'error',
|
||||
error,
|
||||
});
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const subdocsHandler = (event: {
|
||||
loaded: Set<Doc>;
|
||||
removed: Set<Doc>;
|
||||
added: Set<Doc>;
|
||||
}) => {
|
||||
event.loaded.forEach(subdoc => {
|
||||
connectDoc(subdoc).catch(console.error);
|
||||
});
|
||||
event.removed.forEach(subdoc => {
|
||||
disposeDoc(subdoc);
|
||||
});
|
||||
};
|
||||
|
||||
doc.on('update', updateHandler);
|
||||
doc.on('subdocs', subdocsHandler);
|
||||
// todo: handle destroy?
|
||||
disposables.add(() => {
|
||||
doc.off('update', updateHandler);
|
||||
doc.off('subdocs', subdocsHandler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners for the datasource.
|
||||
* Specifically, listens for updates to documents and applies them to the corresponding Yjs document.
|
||||
*/
|
||||
function setupDatasourceListeners() {
|
||||
assertExists(abortController, 'abortController should be defined');
|
||||
const unsubscribe = datasource.onDocUpdate?.((guid, update) => {
|
||||
changeStatus({
|
||||
type: 'syncing',
|
||||
});
|
||||
const doc = getDoc(rootDoc, guid);
|
||||
if (doc) {
|
||||
applyUpdate(doc, update, origin);
|
||||
//
|
||||
if (pendingMap.has(guid)) {
|
||||
pendingMap
|
||||
.get(guid)
|
||||
?.forEach(update => applyUpdate(doc, update, origin));
|
||||
pendingMap.delete(guid);
|
||||
}
|
||||
} else {
|
||||
// This case happens when the father doc is not yet updated,
|
||||
// so that the child doc is not yet created.
|
||||
// We need to put it into cache so that it can be applied later.
|
||||
console.warn('doc not found', guid);
|
||||
pendingMap.set(guid, (pendingMap.get(guid) ?? []).concat(update));
|
||||
}
|
||||
changeStatus({
|
||||
type: 'synced',
|
||||
});
|
||||
});
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
unsubscribe?.();
|
||||
});
|
||||
}
|
||||
|
||||
// when a subdoc is loaded, we need to sync it with the datasource and setup listeners
|
||||
async function connectDoc(doc: Doc) {
|
||||
// skip if already connected
|
||||
if (connectedDocs.has(doc.guid)) {
|
||||
return;
|
||||
}
|
||||
connectedDocs.add(doc.guid);
|
||||
setupDocListener(doc);
|
||||
await syncDoc(doc);
|
||||
|
||||
await Promise.all(
|
||||
[...doc.subdocs]
|
||||
.filter(subdoc => subdoc.shouldLoad)
|
||||
.map(subdoc => connectDoc(subdoc))
|
||||
);
|
||||
}
|
||||
|
||||
function disposeDoc(doc: Doc) {
|
||||
connectedDocs.delete(doc.guid);
|
||||
const disposables = disposableMap.get(doc.guid);
|
||||
if (disposables) {
|
||||
disposables.forEach(dispose => dispose());
|
||||
disposableMap.delete(doc.guid);
|
||||
}
|
||||
// also dispose all subdocs
|
||||
doc.subdocs.forEach(disposeDoc);
|
||||
}
|
||||
|
||||
function disposeAll() {
|
||||
disposableMap.forEach(disposables => {
|
||||
disposables.forEach(dispose => dispose());
|
||||
});
|
||||
disposableMap.clear();
|
||||
connectedDocs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the datasource and sets up event listeners for document updates.
|
||||
*/
|
||||
function connect() {
|
||||
connected = true;
|
||||
abortController = new AbortController();
|
||||
|
||||
changeStatus({
|
||||
type: 'syncing',
|
||||
});
|
||||
// root doc should be already loaded,
|
||||
// but we want to populate the cache for later update events
|
||||
connectDoc(rootDoc)
|
||||
.then(() => {
|
||||
changeStatus({
|
||||
type: 'synced',
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
changeStatus({
|
||||
type: 'error',
|
||||
error,
|
||||
});
|
||||
console.error(error);
|
||||
});
|
||||
setupDatasourceListeners();
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
connected = false;
|
||||
disposeAll();
|
||||
assertExists(abortController, 'abortController should be defined');
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
|
||||
const syncDocRecursive = async (doc: Doc) => {
|
||||
await syncDoc(doc);
|
||||
await Promise.all(
|
||||
[...doc.subdocs.values()].map(subdoc => syncDocRecursive(subdoc))
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
sync: async onlyRootDoc => {
|
||||
connected = true;
|
||||
try {
|
||||
if (onlyRootDoc) {
|
||||
await syncDoc(rootDoc);
|
||||
} else {
|
||||
await syncDocRecursive(rootDoc);
|
||||
}
|
||||
} finally {
|
||||
connected = false;
|
||||
}
|
||||
},
|
||||
get status() {
|
||||
return currentStatus;
|
||||
},
|
||||
subscribeStatusChange(cb: () => void) {
|
||||
callbackSet.add(cb);
|
||||
return () => {
|
||||
callbackSet.delete(cb);
|
||||
};
|
||||
},
|
||||
get connected() {
|
||||
return connected;
|
||||
},
|
||||
passive: true,
|
||||
connect,
|
||||
disconnect,
|
||||
|
||||
datasource,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DocDataSource } from './data-source';
|
||||
|
||||
export type Status =
|
||||
| {
|
||||
type: 'idle';
|
||||
}
|
||||
| {
|
||||
type: 'syncing';
|
||||
}
|
||||
| {
|
||||
type: 'synced';
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export interface DataSourceAdapter {
|
||||
datasource: DocDataSource;
|
||||
readonly status: Status;
|
||||
|
||||
subscribeStatusChange(onStatusChange: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface DocState {
|
||||
/**
|
||||
* The missing structs of client queries with self state.
|
||||
*/
|
||||
missing: Uint8Array;
|
||||
|
||||
/**
|
||||
* The full state of remote, used to prepare for diff sync.
|
||||
*/
|
||||
state?: Uint8Array;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Doc } from 'yjs';
|
||||
|
||||
export function getDoc(doc: Doc, guid: string): Doc | undefined {
|
||||
if (doc.guid === guid) {
|
||||
return doc;
|
||||
}
|
||||
for (const subdoc of doc.subdocs) {
|
||||
const found = getDoc(subdoc, guid);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const saveAlert = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
return (event.returnValue =
|
||||
'Data is not saved. Are you sure you want to leave?');
|
||||
};
|
||||
|
||||
export const writeOperation = async (op: Promise<unknown>) => {
|
||||
window.addEventListener('beforeunload', saveAlert, {
|
||||
capture: true,
|
||||
});
|
||||
await op;
|
||||
window.removeEventListener('beforeunload', saveAlert, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
fileName: 'index',
|
||||
name: 'ToEverythingIndexedDBProvider',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['idb', 'yjs'],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: resolve(__dirname, 'src'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user