refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions
+21
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
# copied directly from https://github.com/pacocoursey/cmdk
will remove after a new CMDK version is published to npm
+12
View File
@@ -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"
}
+179
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
}
}
+1
View File
@@ -0,0 +1 @@
lib
+3
View File
@@ -0,0 +1,3 @@
# @affine/debug
A common debug interface for packages in this repository.
+13
View File
@@ -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();
}
});
});
+61
View File
@@ -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);
}
}
+13
View File
@@ -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"]
}
+1
View File
@@ -0,0 +1 @@
lib
+33
View File
@@ -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
View File
@@ -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;
};
+131
View File
@@ -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);
}
}
+69
View File
@@ -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'];
+161
View File
@@ -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;
}
+8
View File
@@ -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
);
}
+7
View File
@@ -0,0 +1,7 @@
export type PageInfo = {
isEdgeless: boolean;
title: string;
id: string;
};
export type GetPageInfoById = (id: string) => PageInfo | undefined;
+78
View File
@@ -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,
};
};
+208
View File
@@ -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>;
}
+132
View File
@@ -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(),
});
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
},
"references": [
{
"path": "../infra"
},
{
"path": "../../../tests/fixtures"
}
]
}
+115
View File
@@ -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
View File
@@ -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';
+18
View File
@@ -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.
+63
View File
@@ -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;
}
+51
View File
@@ -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
> {}
+2
View File
@@ -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: {} };
}
}
+279
View File
@@ -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;
}
+18
View File
@@ -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"
}
]
}
+17
View File
@@ -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"
}
]
}
+47
View File
@@ -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()],
});
+4
View File
@@ -0,0 +1,4 @@
src/entry.d.ts
src/entry.d.ts.map
src/entry.js
src/entry.js.map
+37
View File
@@ -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"
}
}
+22
View File
@@ -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"]
}
+65
View File
@@ -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>;
+4
View File
@@ -0,0 +1,4 @@
export interface ServerContext {
registerCommand: (command: string, fn: (...args: any[]) => any) => void;
unregisterCommand: (command: string) => void;
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"moduleResolution": "bundler",
"outDir": "lib"
},
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"outDir": "lib",
"noEmit": false
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -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()],
});
+1
View File
@@ -0,0 +1 @@
This package has been moved to [toeverything/design](https://github.com/toeverything/design)
+1
View File
@@ -0,0 +1 @@
lib
+38
View File
@@ -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
View File
@@ -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();
+52
View File
@@ -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"
}
}
+21
View File
@@ -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)));
}
});
});
+134
View File
@@ -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';
+162
View File
@@ -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;
};
+50
View File
@@ -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;
};
}
+205
View File
@@ -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);
}
}
+17
View File
@@ -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'),
}),
],
});
+8
View File
@@ -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)`.
+36
View File
@@ -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);
}
});
})
);
}
+4
View File
@@ -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,
};
};
+35
View File
@@ -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;
}
+30
View File
@@ -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,
});
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
}
}
+27
View File
@@ -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'),
}),
],
});