snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
{
"name": "@toeverything/infra",
"type": "module",
"private": true,
"sideEffects": false,
"exports": {
"./storage": "./src/storage/index.ts",
"./utils": "./src/utils/index.ts",
"./app-config-storage": "./src/app-config-storage.ts",
"./op": "./src/op/index.ts",
"./atom": "./src/atom/index.ts",
".": "./src/index.ts"
},
"dependencies": {
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/error": "workspace:*",
"@affine/templates": "workspace:*",
"@preact/signals-core": "^1.8.0",
"eventemitter2": "^6.4.9",
"fractional-indexing": "^3.2.0",
"jotai": "^2.10.3",
"jotai-effect": "^2.0.0",
"lodash-es": "^4.17.23",
"nanoid": "^5.1.6",
"react": "^19.2.1",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.1",
"rxjs": "^7.8.2",
"vitest": "^4.1.8"
},
"peerDependencies": {
"electron": "*",
"react-dom": "^19.0.0"
},
"version": "0.27.5"
}

View File

@@ -0,0 +1,92 @@
import { z } from 'zod';
export const appConfigSchema = z.object({
/** whether to show onboarding first */
onBoarding: z.boolean().optional().default(true),
});
export type AppConfigSchema = z.infer<typeof appConfigSchema>;
export const defaultAppConfig = appConfigSchema.parse({});
const _storage: Record<number, any> = {};
let _inMemoryId = 0;
interface StorageOptions<T> {
/** default config */
config: T;
get?: () => T;
set?: (data: T) => void;
}
/**
* Storage for app configuration, stored in memory by default
*/
class Storage<T extends object> {
private _cfg: T;
private readonly _id = _inMemoryId++;
private readonly _options;
constructor(options: StorageOptions<T>) {
this._options = {
get: () => _storage[this._id],
set: (data: T) => (_storage[this._id] = data),
...options,
};
this._cfg = this.get() ?? options.config;
}
/**
* update entire config
* @param data
*/
set(data: T) {
try {
this._options.set(data);
} catch (err) {
console.error('failed to save config', err);
}
this._cfg = data;
}
get(): T;
get<K extends keyof T>(key: K): T[K];
/**
* get config, if key is provided, return the value of the key
* @param key
* @returns
*/
get(key?: keyof T): T | T[keyof T] {
if (!key) {
try {
const cfg = this._options.get();
if (!cfg) {
this.set(this._options.config);
return this._options.config;
}
return cfg;
} catch {
return this._cfg;
}
} else {
const fullConfig = this.get();
// TODO(@catsjuice): handle key not found, set default value
// if (!(key in fullConfig)) {}
return fullConfig[key];
}
}
/**
* update a key in config
* @param key
* @param value
*/
patch(key: keyof T, value: any) {
this.set({ ...this.get(), [key]: value });
}
get value(): T {
return this.get();
}
}
export class AppConfigStorage extends Storage<AppConfigSchema> {}

View File

@@ -0,0 +1,2 @@
export * from './root-store';
export * from './settings';

View File

@@ -0,0 +1,5 @@
import { getDefaultStore } from 'jotai';
export function getCurrentStore() {
return getDefaultStore();
}

View File

@@ -0,0 +1,82 @@
import { DebugLogger } from '@affine/debug';
import { setupGlobal } from '@affine/env/global';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { atomEffect } from 'jotai-effect';
setupGlobal();
const logger = new DebugLogger('affine:settings');
export type AppSetting = {
clientBorder: boolean;
windowFrameStyle: 'frameless' | 'NativeTitleBar';
enableBlurBackground: boolean;
enableNoisyBackground: boolean;
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
enableTelemetry: boolean;
showLinkedDocInSidebar: boolean;
disableImageAntialiasing: boolean;
};
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
'frameless',
'NativeTitleBar',
];
export const APP_SETTINGS_STORAGE_KEY = 'affine-settings';
const appSettingBaseAtom = atomWithStorage<AppSetting>(
APP_SETTINGS_STORAGE_KEY,
{
clientBorder: BUILD_CONFIG.isElectron && !environment.isWindows,
windowFrameStyle: 'frameless',
enableBlurBackground: BUILD_CONFIG.isElectron && environment.isMacOs,
enableNoisyBackground: true,
autoCheckUpdate: true,
autoDownloadUpdate: true,
enableTelemetry: true,
showLinkedDocInSidebar: true,
disableImageAntialiasing: false,
},
undefined,
{
getOnInit: true,
}
);
type SetStateAction<Value> = Value | ((prev: Value) => Value);
// todo(@pengx17): use global state instead
const appSettingEffect = atomEffect(get => {
const settings = get(appSettingBaseAtom);
// some values in settings should be synced into electron side
if (BUILD_CONFIG.isElectron) {
logger.debug('sync settings to electron', settings);
// this api type in @affine/electron-api, but it is circular dependency this package, use any here
(window as any).__apis?.updater
.setConfig({
autoCheckUpdate: settings.autoCheckUpdate,
autoDownloadUpdate: settings.autoDownloadUpdate,
})
.catch((err: any) => {
console.error(err);
});
}
});
export const appSettingAtom = atom<
AppSetting,
[SetStateAction<Partial<AppSetting>>],
void
>(
get => {
get(appSettingEffect);
return get(appSettingBaseAtom);
},
(_get, set, apply) => {
set(appSettingBaseAtom, prev => {
const next = typeof apply === 'function' ? apply(prev) : apply;
return { ...prev, ...next };
});
}
);

View File

@@ -0,0 +1,539 @@
import { describe, expect, test } from 'vitest';
import {
CircularDependencyError,
ComponentNotFoundError,
createEvent,
createIdentifier,
DuplicateDefinitionError,
Entity,
Framework,
MissingDependencyError,
RecursionLimitError,
Scope,
Service,
} from '..';
import { OnEvent } from '../core/event';
describe('framework', () => {
test('basic', () => {
const framework = new Framework();
class TestService extends Service {
a = 'b';
}
framework.service(TestService);
const provider = framework.provider();
expect(provider.get(TestService).a).toBe('b');
});
test('entity', () => {
const framework = new Framework();
class TestService extends Service {
a = 'b';
}
class TestEntity extends Entity<{ name: string }> {
constructor(readonly test: TestService) {
super();
}
}
framework.service(TestService).entity(TestEntity, [TestService]);
const provider = framework.provider();
const entity = provider.createEntity(TestEntity, {
name: 'test',
});
expect(entity.test.a).toBe('b');
expect(entity.props.name).toBe('test');
});
test('componentCount', () => {
const framework = new Framework();
class TestService extends Service {
a = 'b';
}
framework.service(TestService);
expect(framework.componentCount).toEqual(1);
});
test('dependency', () => {
const framework = new Framework();
class A extends Service {
value = 'hello world';
}
class B extends Service {
constructor(public a: A) {
super();
}
}
class C extends Service {
constructor(public b: B) {
super();
}
}
framework.service(A).service(B, [A]).service(C, [B]);
const provider = framework.provider();
expect(provider.get(C).b.a.value).toEqual('hello world');
});
test('identifier', () => {
interface Animal extends Service {
name: string;
}
const Animal = createIdentifier<Animal>('Animal');
class Cat extends Service {
name = 'cat';
}
class Zoo extends Service {
constructor(public animal: Animal) {
super();
}
}
const serviceCollection = new Framework();
serviceCollection.impl(Animal, Cat).service(Zoo, [Animal]);
const provider = serviceCollection.provider();
expect(provider.get(Zoo).animal.name).toEqual('cat');
});
test('variant', () => {
const framework = new Framework();
interface USB extends Service {
speed: number;
}
const USB = createIdentifier<USB>('USB');
class TypeA extends Service implements USB {
speed = 100;
}
class TypeC extends Service implements USB {
speed = 300;
}
class PC extends Service {
constructor(
public typeA: USB,
public ports: USB[]
) {
super();
}
}
framework
.impl(USB('A'), TypeA)
.impl(USB('C'), TypeC)
.service(PC, [USB('A'), [USB]]);
const provider = framework.provider();
expect(provider.get(USB('A')).speed).toEqual(100);
expect(provider.get(USB('C')).speed).toEqual(300);
expect(provider.get(PC).typeA.speed).toEqual(100);
expect(provider.get(PC).ports.length).toEqual(2);
});
test('lazy initialization', () => {
const framework = new Framework();
interface Command {
shortcut: string;
callback: () => void;
}
const Command = createIdentifier<Command>('command');
let pageSystemInitialized = false;
class PageSystem extends Service {
mode = 'page';
name = 'helloworld';
constructor() {
super();
pageSystemInitialized = true;
}
switchToEdgeless() {
this.mode = 'edgeless';
}
rename() {
this.name = 'foobar';
}
}
class CommandSystem extends Service {
constructor(public commands: Command[]) {
super();
}
execute(shortcut: string) {
const command = this.commands.find(c => c.shortcut === shortcut);
if (command) {
command.callback();
}
}
}
framework.service(PageSystem);
framework.service(CommandSystem, [[Command]]);
framework.impl(Command('switch'), p => ({
shortcut: 'option+s',
callback: () => p.get(PageSystem).switchToEdgeless(),
}));
framework.impl(Command('rename'), p => ({
shortcut: 'f2',
callback: () => p.get(PageSystem).rename(),
}));
const provider = framework.provider();
const commandSystem = provider.get(CommandSystem);
expect(
pageSystemInitialized,
"PageSystem won't be initialized until command executed"
).toEqual(false);
commandSystem.execute('option+s');
expect(pageSystemInitialized).toEqual(true);
expect(provider.get(PageSystem).mode).toEqual('edgeless');
expect(provider.get(PageSystem).name).toEqual('helloworld');
expect(commandSystem.commands.length).toEqual(2);
commandSystem.execute('f2');
expect(provider.get(PageSystem).name).toEqual('foobar');
});
test('duplicate, override', () => {
const framework = new Framework();
const something = createIdentifier<any>('USB');
class A {
a = 'i am A';
}
class B {
b = 'i am B';
}
framework.impl(something, A).override(something, B);
const provider = framework.provider();
expect(provider.get(something)).toEqual({ b: 'i am B' });
});
test('event', () => {
const framework = new Framework();
const event = createEvent<{ value: number }>('test-event');
@OnEvent(event, p => p.onTestEvent)
class TestService extends Service {
value = 0;
onTestEvent(payload: { value: number }) {
this.value = payload.value;
}
}
framework.service(TestService);
const provider = framework.provider();
provider.emitEvent(event, { value: 123 });
expect(provider.get(TestService).value).toEqual(123);
});
test('scope', () => {
const framework = new Framework();
class SystemService extends Service {
appName = 'affine';
}
framework.service(SystemService);
class WorkspaceScope extends Scope {}
class WorkspaceService extends Service {
constructor(public system: SystemService) {
super();
}
}
framework.scope(WorkspaceScope).service(WorkspaceService, [SystemService]);
class PageScope extends Scope<{ pageId: string }> {}
class PageService extends Service {
constructor(
public workspace: WorkspaceService,
public system: SystemService
) {
super();
}
}
framework
.scope(WorkspaceScope)
.scope(PageScope)
.service(PageService, [WorkspaceService, SystemService]);
class EditorScope extends Scope {
get pageId() {
return this.framework.get(PageScope).props.pageId;
}
}
class EditorService extends Service {
constructor(public page: PageService) {
super();
}
}
framework
.scope(WorkspaceScope)
.scope(PageScope)
.scope(EditorScope)
.service(EditorService, [PageService]);
const root = framework.provider();
expect(root.get(SystemService).appName).toEqual('affine');
expect(() => root.get(WorkspaceService)).toThrowError(
ComponentNotFoundError
);
const workspaceScope = root.createScope(WorkspaceScope);
const workspaceService = workspaceScope.get(WorkspaceService);
expect(workspaceService.system.appName).toEqual('affine');
expect(() => workspaceScope.get(PageService)).toThrowError(
ComponentNotFoundError
);
const pageScope = workspaceScope.createScope(PageScope, {
pageId: 'test-page',
});
expect(pageScope.props.pageId).toEqual('test-page');
const pageService = pageScope.get(PageService);
expect(pageService.workspace).toBe(workspaceService);
expect(pageService.system.appName).toEqual('affine');
const editorScope = pageScope.createScope(EditorScope);
expect(editorScope.pageId).toEqual('test-page');
const editorService = editorScope.get(EditorService);
expect(editorService.page).toBe(pageService);
});
test('scope event', () => {
const framework = new Framework();
const event = createEvent<{ value: number }>('test-event');
@OnEvent(event, p => p.onTestEvent)
class TestService extends Service {
value = 0;
onTestEvent(payload: { value: number }) {
this.value = payload.value;
}
}
class TestScope extends Scope {}
@OnEvent(event, p => p.onTestEvent)
class TestScopeService extends Service {
value = 0;
onTestEvent(payload: { value: number }) {
this.value = payload.value;
}
}
framework.service(TestService).scope(TestScope).service(TestScopeService);
const provider = framework.provider();
const scope = provider.createScope(TestScope);
scope.emitEvent(event, { value: 123 });
expect(provider.get(TestService).value).toEqual(0);
expect(scope.get(TestScopeService).value).toEqual(123);
});
test('dispose', () => {
const framework = new Framework();
let isSystemDisposed = false;
class System extends Service {
appName = 'affine';
override dispose(): void {
super.dispose();
isSystemDisposed = true;
}
}
framework.service(System);
let isWorkspaceDisposed = false;
class WorkspaceScope extends Scope {
override dispose(): void {
super.dispose();
isWorkspaceDisposed = true;
}
}
let isWorkspacePageServiceDisposed = false;
class WorkspacePageService extends Service {
constructor(
public workspace: WorkspaceScope,
public sysmte: System
) {
super();
}
override dispose(): void {
super.dispose();
isWorkspacePageServiceDisposed = true;
}
}
framework
.scope(WorkspaceScope)
.service(WorkspacePageService, [WorkspaceScope, System]);
{
using root = framework.provider();
{
// create a workspace
using workspaceScope = root.createScope(WorkspaceScope);
const pageService = workspaceScope.get(WorkspacePageService);
expect(pageService).instanceOf(WorkspacePageService);
expect(
isSystemDisposed ||
isWorkspaceDisposed ||
isWorkspacePageServiceDisposed
).toBe(false);
}
expect(isWorkspaceDisposed && isWorkspacePageServiceDisposed).toBe(true);
expect(isSystemDisposed).toBe(false);
}
expect(isSystemDisposed).toBe(true);
});
test('service not found', () => {
const framework = new Framework();
const provider = framework.provider();
expect(() => provider.get(createIdentifier('SomeService'))).toThrowError(
ComponentNotFoundError
);
});
test('missing dependency', () => {
const framework = new Framework();
class A extends Service {
value = 'hello world';
}
class B extends Service {
constructor(public a: A) {
super();
}
}
framework.service(B, [A]);
const provider = framework.provider();
expect(() => provider.get(B)).toThrowError(MissingDependencyError);
});
test('circular dependency', () => {
const framework = new Framework();
class A extends Service {
constructor(public c: C) {
super();
}
}
class B extends Service {
constructor(public a: A) {
super();
}
}
class C extends Service {
constructor(public b: B) {
super();
}
}
framework.service(A, [C]).service(B, [A]).service(C, [B]);
const provider = framework.provider();
expect(() => provider.get(A)).toThrowError(CircularDependencyError);
expect(() => provider.get(B)).toThrowError(CircularDependencyError);
expect(() => provider.get(C)).toThrowError(CircularDependencyError);
});
test('duplicate service definition', () => {
const serviceCollection = new Framework();
class A extends Service {}
serviceCollection.service(A);
expect(() => serviceCollection.service(A)).toThrowError(
DuplicateDefinitionError
);
class B {}
const Something = createIdentifier('something');
serviceCollection.impl(Something, A);
expect(() => serviceCollection.impl(Something, B)).toThrowError(
DuplicateDefinitionError
);
});
test('recursion limit', () => {
// maxmium resolve depth is 100
const serviceCollection = new Framework();
const Something = createIdentifier('something');
let i = 0;
for (; i < 100; i++) {
const next = i + 1;
class Test {
constructor(_next: any) {}
}
serviceCollection.impl(Something(i.toString()), Test, [
Something(next.toString()),
]);
}
class Final {
a = 'b';
}
serviceCollection.impl(Something(i.toString()), Final);
const provider = serviceCollection.provider();
expect(() => provider.get(Something('0'))).toThrowError(
RecursionLimitError
);
});
});

View File

@@ -0,0 +1,31 @@
import { CONSTRUCTOR_CONTEXT } from '../constructor-context';
import type { FrameworkProvider } from '../provider';
export class Component<Props = {}> {
readonly framework: FrameworkProvider;
readonly props: Props;
protected readonly disposables: (() => void)[] = [];
get eventBus() {
return this.framework.eventBus;
}
constructor() {
if (!CONSTRUCTOR_CONTEXT.current.provider) {
throw new Error('Component must be created in the context of a provider');
}
this.framework = CONSTRUCTOR_CONTEXT.current.provider;
this.props = CONSTRUCTOR_CONTEXT.current.props;
CONSTRUCTOR_CONTEXT.current = {};
}
dispose() {
this.disposables.forEach(dispose => dispose());
}
[Symbol.dispose]() {
this.dispose();
}
}

View File

@@ -0,0 +1,5 @@
import { Component } from './component';
export class Entity<Props = {}> extends Component<Props> {
readonly __isEntity = true;
}

View File

@@ -0,0 +1,42 @@
import { Component } from './component';
export class Scope<Props = {}> extends Component<Props> {
readonly __injectable = true;
get collection() {
return this.framework.collection;
}
get scope() {
return this.framework.scope;
}
get get() {
return this.framework.get;
}
get getAll() {
return this.framework.getAll;
}
get getOptional() {
return this.framework.getOptional;
}
get createEntity() {
return this.framework.createEntity;
}
get createScope() {
return this.framework.createScope;
}
get emitEvent() {
return this.framework.emitEvent;
}
override dispose(): void {
super.dispose();
this.framework.dispose();
}
}

View File

@@ -0,0 +1,6 @@
import { Component } from './component';
export class Service extends Component {
readonly __isService = true;
readonly __injectable = true;
}

View File

@@ -0,0 +1,6 @@
import { Component } from './component';
export class Store extends Component {
readonly __isStore = true;
readonly __injectable = true;
}

View File

@@ -0,0 +1,23 @@
import type { FrameworkProvider } from './provider';
interface Context {
provider?: FrameworkProvider;
props?: any;
}
export const CONSTRUCTOR_CONTEXT: {
current: Context;
} = { current: {} };
/**
* @internal
*/
export function withContext<T>(cb: () => T, context: Context): T {
const pre = CONSTRUCTOR_CONTEXT.current;
try {
CONSTRUCTOR_CONTEXT.current = context;
return cb();
} finally {
CONSTRUCTOR_CONTEXT.current = pre;
}
}

View File

@@ -0,0 +1,6 @@
import type { ComponentVariant } from './types';
export const DEFAULT_VARIANT: ComponentVariant = 'default';
export const ROOT_SCOPE = [];
export const SUB_COMPONENTS = Symbol('subComponents');

View File

@@ -0,0 +1,59 @@
import { DEFAULT_VARIANT } from './consts';
import type { IdentifierValue } from './types';
export class RecursionLimitError extends Error {
constructor() {
super('Dynamic resolve recursion limit reached');
}
}
export class CircularDependencyError extends Error {
constructor(public readonly dependencyStack: IdentifierValue[]) {
super(
`A circular dependency was detected.\n` +
stringifyDependencyStack(dependencyStack)
);
}
}
export class ComponentNotFoundError extends Error {
constructor(public readonly identifier: IdentifierValue) {
super(
`Component ${stringifyIdentifier(identifier)} not found in container`
);
}
}
export class MissingDependencyError extends Error {
constructor(
public readonly from: IdentifierValue,
public readonly target: IdentifierValue,
public readonly dependencyStack: IdentifierValue[]
) {
super(
`Missing dependency ${stringifyIdentifier(
target
)} in creating ${stringifyIdentifier(
from
)}.\n${stringifyDependencyStack(dependencyStack)}`
);
}
}
export class DuplicateDefinitionError extends Error {
constructor(public readonly identifier: IdentifierValue) {
super(`${stringifyIdentifier(identifier)} already exists`);
}
}
function stringifyIdentifier(identifier: IdentifierValue) {
return `[${identifier.identifierName}]${
identifier.variant !== DEFAULT_VARIANT ? `(${identifier.variant})` : ''
}`;
}
function stringifyDependencyStack(dependencyStack: IdentifierValue[]) {
return dependencyStack
.map(identifier => `${stringifyIdentifier(identifier)}`)
.join(' -> ');
}

View File

@@ -0,0 +1,126 @@
import { DebugLogger } from '@affine/debug';
import { stableHash } from '../../utils';
import type { FrameworkProvider } from '.';
import type { Service } from './components/service';
import { SUB_COMPONENTS } from './consts';
import { createIdentifier } from './identifier';
import type { SubComponent } from './types';
export interface FrameworkEvent<T> {
id: string;
_type: T;
}
export function createEvent<T>(id: string): FrameworkEvent<T> {
return { id, _type: {} as T };
}
export type FrameworkEventType<T> =
T extends FrameworkEvent<infer E> ? E : never;
const logger = new DebugLogger('affine:event-bus');
export class EventBus {
private listeners: Record<string, Array<(payload: any) => void>> = {};
constructor(
provider: FrameworkProvider,
private readonly parent?: EventBus
) {
const handlers = provider.getAll(EventHandler, {
sameScope: true,
});
for (const handler of handlers.values()) {
this.on(handler.event, handler.handler);
}
}
get root(): EventBus {
return this.parent?.root ?? this;
}
on<T>(event: FrameworkEvent<T>, listener: (event: T) => void) {
if (!this.listeners[event.id]) {
this.listeners[event.id] = [];
}
this.listeners[event.id].push(listener);
const off = this.parent?.on(event, listener);
return () => {
off?.();
this.off(event.id, listener);
};
}
private off(eventId: string, listener: (event: any) => void) {
if (!this.listeners[eventId]) {
return;
}
this.listeners[eventId] = this.listeners[eventId].filter(
l => l !== listener
);
}
emit<T>(event: FrameworkEvent<T>, payload: T) {
logger.debug('Emitting event', event.id, payload);
const listeners = this.listeners[event.id];
if (!listeners) {
return;
}
listeners.forEach(listener => {
try {
listener(payload);
} catch (e) {
console.error(e);
}
});
}
dispose(): void {
for (const eventId of Object.keys(this.listeners)) {
for (const listener of this.listeners[eventId]) {
this.parent?.off(eventId, listener);
}
}
this.listeners = {};
}
}
interface EventHandler {
event: FrameworkEvent<any>;
handler: (payload: any) => void;
}
export const EventHandler = createIdentifier<EventHandler>('EventHandler');
export const OnEvent = <
E extends FrameworkEvent<any>,
C extends abstract new (...args: any) => any,
I = InstanceType<C>,
>(
e: E,
pick: I extends Service ? (i: I) => (e: FrameworkEventType<E>) => void : never
) => {
return (target: C): C => {
const handlers = (target as any)[SUB_COMPONENTS] ?? [];
(target as any)[SUB_COMPONENTS] = [
...handlers,
{
identifier: EventHandler(
target.name + stableHash(e) + stableHash(pick)
),
factory: provider => {
return {
event: e,
handler: (payload: any) => {
const i = provider.get(target);
pick(i).apply(i, [payload]);
},
} satisfies EventHandler;
},
} satisfies SubComponent,
];
return target;
};
};

View File

@@ -0,0 +1,524 @@
import type { Entity } from './components/entity';
import type { Scope } from './components/scope';
import type { Service } from './components/service';
import type { Store } from './components/store';
import { DEFAULT_VARIANT, ROOT_SCOPE, SUB_COMPONENTS } from './consts';
import { DuplicateDefinitionError } from './error';
import { parseIdentifier } from './identifier';
import type { FrameworkProvider } from './provider';
import { BasicFrameworkProvider } from './provider';
import { stringifyScope } from './scope';
import type {
ComponentFactory,
ComponentVariant,
FrameworkScopeStack,
GeneralIdentifier,
Identifier,
IdentifierType,
IdentifierValue,
SubComponent,
Type,
TypesToDeps,
} from './types';
export class Framework {
private readonly components: Map<
string,
Map<string, Map<ComponentVariant, ComponentFactory>>
> = new Map();
/**
* Create an empty framework.
*
* same as `new Framework()`
*/
static get EMPTY() {
return new Framework();
}
/**
* The number of components in the framework.
*/
get componentCount() {
let count = 0;
for (const [, identifiers] of this.components) {
for (const [, variants] of identifiers) {
count += variants.size;
}
}
return count;
}
/**
* @see {@link FrameworkEditor.service}
*/
get service() {
return new FrameworkEditor(this).service;
}
/**
* @see {@link FrameworkEditor.impl}
*/
get impl() {
return new FrameworkEditor(this).impl;
}
/**
* @see {@link FrameworkEditor.entity}
*/
get entity() {
return new FrameworkEditor(this).entity;
}
/**
* @see {@link FrameworkEditor.scope}
*/
get scope() {
return new FrameworkEditor(this).scope;
}
/**
* @see {@link FrameworkEditor.override}
*/
get override() {
return new FrameworkEditor(this).override;
}
/**
* @see {@link FrameworkEditor.store}
*/
get store() {
return new FrameworkEditor(this).store;
}
/**
* @internal Use {@link impl} instead.
*/
addValue<T>(
identifier: GeneralIdentifier<T>,
value: T,
{
scope,
override,
}: { scope?: FrameworkScopeStack; override?: boolean } = {}
) {
this.addFactory(parseIdentifier(identifier) as Identifier<T>, () => value, {
scope,
override,
});
}
/**
* @internal Use {@link impl} instead.
*/
addFactory<T>(
identifier: GeneralIdentifier<T>,
factory: ComponentFactory<T>,
{
scope,
override,
}: { scope?: FrameworkScopeStack; override?: boolean } = {}
) {
// convert scope to string
const normalizedScope = stringifyScope(scope ?? ROOT_SCOPE);
const normalizedIdentifier = parseIdentifier(identifier);
const normalizedVariant = normalizedIdentifier.variant ?? DEFAULT_VARIANT;
const services =
this.components.get(normalizedScope) ??
new Map<string, Map<ComponentVariant, ComponentFactory>>();
const variants =
services.get(normalizedIdentifier.identifierName) ??
new Map<ComponentVariant, ComponentFactory>();
// throw if service already exists, unless it is an override
if (variants.has(normalizedVariant) && !override) {
throw new DuplicateDefinitionError(normalizedIdentifier);
}
variants.set(normalizedVariant, factory);
services.set(normalizedIdentifier.identifierName, variants);
this.components.set(normalizedScope, services);
}
remove(identifier: IdentifierValue, scope: FrameworkScopeStack = ROOT_SCOPE) {
const normalizedScope = stringifyScope(scope);
const normalizedIdentifier = parseIdentifier(identifier);
const normalizedVariant = normalizedIdentifier.variant ?? DEFAULT_VARIANT;
const services = this.components.get(normalizedScope);
if (!services) {
return;
}
const variants = services.get(normalizedIdentifier.identifierName);
if (!variants) {
return;
}
variants.delete(normalizedVariant);
}
/**
* Create a service provider from the collection.
*
* @example
* ```ts
* provider() // create a service provider for root scope
* provider(ScopeA, parentProvider) // create a service provider for scope A
* ```
*
* @param scope The scope of the service provider, default to the root scope.
* @param parent The parent service provider, it is required if the scope is not the root scope.
*/
provider(
scope: FrameworkScopeStack = ROOT_SCOPE,
parent: FrameworkProvider | null = null
): FrameworkProvider {
return new BasicFrameworkProvider(this, scope, parent);
}
/**
* @internal
*/
getFactory(
identifier: IdentifierValue,
scope: FrameworkScopeStack = ROOT_SCOPE
): ComponentFactory | undefined {
return this.components
.get(stringifyScope(scope))
?.get(identifier.identifierName)
?.get(identifier.variant ?? DEFAULT_VARIANT);
}
/**
* @internal
*/
getFactoryAll(
identifier: IdentifierValue,
scope: FrameworkScopeStack = ROOT_SCOPE
): Map<ComponentVariant, ComponentFactory> {
return new Map(
this.components.get(stringifyScope(scope))?.get(identifier.identifierName)
);
}
/**
* Clone the entire service collection.
*
* This method is quite cheap as it only clones the references.
*
* @returns A new service collection with the same services.
*/
clone(): Framework {
const di = new Framework();
for (const [scope, identifiers] of this.components) {
const s = new Map();
for (const [identifier, variants] of identifiers) {
s.set(identifier, new Map(variants));
}
di.components.set(scope, s);
}
return di;
}
}
/**
* A helper class to edit a framework.
*/
class FrameworkEditor {
private currentScopeStack: FrameworkScopeStack = ROOT_SCOPE;
constructor(private readonly collection: Framework) {}
/**
* Add a service to the framework.
*
* @see {@link Framework}
*
* @example
* ```ts
* service(ServiceClass, [dependencies, ...])
* ```
*/
service = <
Arg1 extends Type<Service>,
Arg2 extends Deps | ComponentFactory<ServiceType> | ServiceType,
ServiceType = IdentifierType<Arg1>,
Deps = Arg1 extends Type<ServiceType>
? TypesToDeps<ConstructorParameters<Arg1>>
: [],
>(
service: Arg1,
...[arg2]: Arg2 extends [] ? [] : [Arg2]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(service as any, arg2 as any, {
scope: this.currentScopeStack,
});
} else if (arg2 instanceof Array || arg2 === undefined) {
this.collection.addFactory<any>(
service as any,
dependenciesToFactory(service, arg2 as any),
{ scope: this.currentScopeStack }
);
} else {
this.collection.addValue<any>(service as any, arg2, {
scope: this.currentScopeStack,
});
}
if (SUB_COMPONENTS in service) {
const subComponents = (service as any)[SUB_COMPONENTS] as SubComponent[];
for (const { identifier, factory } of subComponents) {
this.collection.addFactory(identifier, factory, {
scope: this.currentScopeStack,
});
}
}
return this;
};
/**
* Add a store to the framework.
*
* @see {@link Framework}
*
* @example
* ```ts
* store(StoreClass, [dependencies, ...])
* ```
*/
store = <
Arg1 extends Type<Store>,
Arg2 extends Deps | ComponentFactory<StoreType> | StoreType,
StoreType = IdentifierType<Arg1>,
Deps = Arg1 extends Type<StoreType>
? TypesToDeps<ConstructorParameters<Arg1>>
: [],
>(
store: Arg1,
...[arg2]: Arg2 extends [] ? [] : [Arg2]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(store as any, arg2 as any, {
scope: this.currentScopeStack,
});
} else if (arg2 instanceof Array || arg2 === undefined) {
this.collection.addFactory<any>(
store as any,
dependenciesToFactory(store, arg2 as any),
{ scope: this.currentScopeStack }
);
} else {
this.collection.addValue<any>(store as any, arg2, {
scope: this.currentScopeStack,
});
}
if (SUB_COMPONENTS in store) {
const subComponents = (store as any)[SUB_COMPONENTS] as SubComponent[];
for (const { identifier, factory } of subComponents) {
this.collection.addFactory(identifier, factory, {
scope: this.currentScopeStack,
});
}
}
return this;
};
/**
* Add an entity to the framework.
*/
entity = <
Arg1 extends Type<Entity<any>>,
Arg2 extends Deps | ComponentFactory<EntityType>,
EntityType = IdentifierType<Arg1>,
Deps = Arg1 extends Type<EntityType>
? TypesToDeps<ConstructorParameters<Arg1>>
: [],
>(
entity: Arg1,
...[arg2]: Arg2 extends [] ? [] : [Arg2]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(entity as any, arg2 as any, {
scope: this.currentScopeStack,
});
} else {
this.collection.addFactory<any>(
entity as any,
dependenciesToFactory(entity, arg2 as any),
{ scope: this.currentScopeStack }
);
}
return this;
};
/**
* Add an implementation for identifier to the collection.
*
* @see {@link Framework}
*
* @example
* ```ts
* addImpl(Identifier, Class, [dependencies, ...])
* or
* addImpl(Identifier, Instance)
* or
* addImpl(Identifier, Factory)
* ```
*/
impl = <
Arg1 extends Identifier<any>,
Arg2 extends Type<Trait> | ComponentFactory<Trait> | Trait,
Arg3 extends Deps,
Trait = IdentifierType<Arg1>,
Deps = Arg2 extends Type<Trait>
? TypesToDeps<ConstructorParameters<Arg2>>
: [],
>(
identifier: Arg1,
arg2: Arg2,
...[arg3]: Arg3 extends [] ? [] : [Arg3]
): this => {
if (arg2 instanceof Function) {
this.collection.addFactory<any>(
identifier,
dependenciesToFactory(arg2, arg3 as any[]),
{ scope: this.currentScopeStack }
);
} else {
this.collection.addValue(identifier, arg2 as any, {
scope: this.currentScopeStack,
});
}
return this;
};
/**
* same as {@link impl} but this method will override the component if it exists.
*
* @see {@link Framework}
*
* @example
* ```ts
* override(Identifier, Class, [dependencies, ...])
* or
* override(Identifier, Instance)
* or
* override(Identifier, Factory)
* ```
*/
override = <
Arg1 extends Identifier<any>,
Arg2 extends Type<Trait> | ComponentFactory<Trait> | Trait,
Arg3 extends Deps,
Trait = IdentifierType<Arg1>,
Deps = Arg2 extends Type<Trait>
? TypesToDeps<ConstructorParameters<Arg2>>
: [],
>(
identifier: Arg1,
arg2: Arg2,
...[arg3]: Arg3 extends [] ? [] : [Arg3]
): this => {
if (arg2 === null) {
this.collection.remove(
parseIdentifier(identifier),
this.currentScopeStack
);
return this;
} else if (arg2 instanceof Function) {
this.collection.addFactory<any>(
identifier,
dependenciesToFactory(arg2, arg3 as any[]),
{ scope: this.currentScopeStack, override: true }
);
} else {
this.collection.addValue(identifier, arg2 as any, {
scope: this.currentScopeStack,
override: true,
});
}
return this;
};
/**
* Set the scope for the service registered subsequently
*
* @example
*
* ```ts
* const ScopeA = createScope('a');
*
* services.scope(ScopeA).add(XXXService, ...);
* ```
*/
scope = (scope: Type<Scope<any>>): this => {
this.currentScopeStack = [
...this.currentScopeStack,
parseIdentifier(scope).identifierName,
];
this.collection.addFactory<any>(
scope as any,
dependenciesToFactory(scope, [] as any),
{ scope: this.currentScopeStack, override: true }
);
return this;
};
}
/**
* Convert dependencies definition to a factory function.
*/
function dependenciesToFactory(
cls: any,
deps: any[] = []
): ComponentFactory<any> {
return (provider: FrameworkProvider) => {
const args = [];
for (const dep of deps) {
let isAll;
let identifier;
if (Array.isArray(dep)) {
if (dep.length !== 1) {
throw new Error('Invalid dependency');
}
isAll = true;
identifier = dep[0];
} else {
isAll = false;
identifier = dep;
}
if (isAll) {
args.push(Array.from(provider.getAll(identifier).values()));
} else {
args.push(provider.get(identifier));
}
}
if (isConstructor(cls)) {
return new cls(...args, provider);
} else {
return cls(...args, provider);
}
};
}
// a hack to check if a function is a constructor
// https://github.com/zloirock/core-js/blob/232c8462c26c75864b4397b7f643a4f57c6981d5/packages/core-js/internals/is-constructor.js#L15
function isConstructor(cls: any) {
try {
Reflect.construct(function () {}, [], cls);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,114 @@
import { stableHash } from '../../utils/stable-hash';
import type { Component } from './components/component';
import { DEFAULT_VARIANT } from './consts';
import type {
ComponentVariant,
Identifier,
IdentifierValue,
Type,
} from './types';
/**
* create a Identifier.
*
* Identifier is used to identify a certain type of service. With the identifier, you can reference one or more services
* without knowing the specific implementation, thereby achieving
* [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control).
*
* @example
* ```ts
* // define a interface
* interface Storage {
* get(key: string): string | null;
* set(key: string, value: string): void;
* }
*
* // create a identifier
* // NOTICE: Highly recommend to use the interface name as the identifier name,
* // so that it is easy to understand. and it is legal to do so in TypeScript.
* const Storage = createIdentifier<Storage>('Storage');
*
* // create a implementation
* class LocalStorage implements Storage {
* get(key: string): string | null {
* return localStorage.getItem(key);
* }
* set(key: string, value: string): void {
* localStorage.setItem(key, value);
* }
* }
*
* // register the implementation to the identifier
* framework.impl(Storage, LocalStorage);
*
* // get the implementation from the identifier
* const storage = framework.provider().get(Storage);
* storage.set('foo', 'bar');
* ```
*
* With identifier:
*
* * You can easily replace the implementation of a `Storage` without changing the code that uses it.
* * You can easily mock a `Storage` for testing.
*
* # Variant
*
* Sometimes, you may want to register multiple implementations for the same interface.
* For example, you may want have both `LocalStorage` and `SessionStorage` for `Storage`,
* and use them in same time.
*
* In this case, you can use `variant` to distinguish them.
*
* ```ts
* const Storage = createIdentifier<Storage>('Storage');
* const LocalStorage = Storage('local');
* const SessionStorage = Storage('session');
*
* framework.impl(LocalStorage, LocalStorageImpl);
* framework.impl(SessionStorage, SessionStorageImpl);
*
* // get the implementation from the identifier
* const localStorage = framework.provider().get(LocalStorage);
* const sessionStorage = framework.provider().get(SessionStorage);
* const storage = framework.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
* ```
*
* @param name unique name of the identifier.
* @param variant The default variant name of the identifier, can be overridden by `identifier("variant")`.
*/
export function createIdentifier<T>(
name: string,
variant: ComponentVariant = DEFAULT_VARIANT
): Identifier<T> & ((variant: ComponentVariant) => Identifier<T>) {
return Object.assign(
(variant: ComponentVariant) => {
return createIdentifier<T>(name, variant);
},
{
identifierName: name,
variant,
}
) as any;
}
/**
* Convert the constructor into a ServiceIdentifier.
* As we always deal with ServiceIdentifier in the DI container.
*
* @internal
*/
export function createIdentifierFromConstructor<T extends Component>(
target: Type<T>
): Identifier<T> {
return createIdentifier<T>(`${target.name}${stableHash(target)}`);
}
export function parseIdentifier(input: any): IdentifierValue {
if (input.identifierName) {
return input as IdentifierValue;
} else if (typeof input === 'function' && input.name) {
return createIdentifierFromConstructor(input);
} else {
throw new Error('Input is not a service identifier.');
}
}

View File

@@ -0,0 +1,11 @@
export { Entity } from './components/entity';
export { Scope } from './components/scope';
export { Service } from './components/service';
export { Store } from './components/store';
export * from './error';
export { createEvent, type FrameworkEvent, OnEvent } from './event';
export { Framework } from './framework';
export { createIdentifier } from './identifier';
export type { ResolveOptions } from './provider';
export { FrameworkProvider, FrameworkStackProvider } from './provider';
export type { GeneralIdentifier, Identifier } from './types';

View File

@@ -0,0 +1,382 @@
import type { Component } from './components/component';
import type { Entity } from './components/entity';
import type { Scope } from './components/scope';
import { withContext } from './constructor-context';
import {
CircularDependencyError,
ComponentNotFoundError,
MissingDependencyError,
RecursionLimitError,
} from './error';
import { EventBus, type FrameworkEvent } from './event';
import type { Framework } from './framework';
import { parseIdentifier } from './identifier';
import type {
ComponentVariant,
FrameworkScopeStack,
GeneralIdentifier,
IdentifierValue,
} from './types';
export interface ResolveOptions {
sameScope?: boolean;
optional?: boolean;
noCache?: boolean;
props?: any;
}
export abstract class FrameworkProvider {
abstract collection: Framework;
abstract scope: FrameworkScopeStack;
abstract getRaw(identifier: IdentifierValue, options?: ResolveOptions): any;
abstract getAllRaw(
identifier: IdentifierValue,
options?: ResolveOptions
): Map<ComponentVariant, any>;
abstract dispose(): void;
abstract eventBus: EventBus;
get = <T>(identifier: GeneralIdentifier<T>, options?: ResolveOptions): T => {
return this.getRaw(parseIdentifier(identifier), {
...options,
optional: false,
});
};
getAll = <T>(
identifier: GeneralIdentifier<T>,
options?: ResolveOptions
): Map<ComponentVariant, T> => {
return this.getAllRaw(parseIdentifier(identifier), {
...options,
});
};
getOptional = <T>(
identifier: GeneralIdentifier<T>,
options?: ResolveOptions
): T | undefined => {
return this.getRaw(parseIdentifier(identifier), {
...options,
optional: true,
});
};
createEntity = <
T extends Entity<any>,
Props extends (T extends Component<infer P> ? P : never),
>(
identifier: GeneralIdentifier<T>,
...[props]: Props extends Record<string, never> ? [] : [Props]
): T => {
return this.getRaw(parseIdentifier(identifier), {
noCache: true,
sameScope: true,
props,
});
};
createScope = <
T extends Scope<any>,
Props extends (T extends Component<infer P> ? P : never),
>(
root: GeneralIdentifier<T>,
...[props]: Props extends Record<string, never> ? [] : [Props]
): T => {
const newProvider = this.collection.provider(
[...this.scope, parseIdentifier(root).identifierName],
this
);
return newProvider.getRaw(parseIdentifier(root), {
sameScope: true,
props,
});
};
emitEvent = <T>(event: FrameworkEvent<T>, payload: T) => {
this.eventBus.emit(event, payload);
};
[Symbol.dispose]() {
this.dispose();
}
}
export class ComponentCachePool {
cache: Map<string, Map<ComponentVariant, any>> = new Map();
getOrInsert(identifier: IdentifierValue, insert: () => any) {
const cache = this.cache.get(identifier.identifierName) ?? new Map();
if (!cache.has(identifier.variant)) {
cache.set(identifier.variant, insert());
}
const cached = cache.get(identifier.variant);
this.cache.set(identifier.identifierName, cache);
return cached;
}
dispose() {
for (const t of this.cache.values()) {
for (const i of t.values()) {
if (typeof i === 'object' && typeof i[Symbol.dispose] === 'function') {
try {
i[Symbol.dispose]();
} catch (err) {
// make a uncaught exception
setTimeout(() => {
throw err;
}, 0);
}
}
}
}
this.cache.clear();
}
[Symbol.dispose]() {
this.dispose();
}
}
class Resolver extends FrameworkProvider {
constructor(
public readonly provider: BasicFrameworkProvider,
public readonly depth = 0,
public readonly stack: IdentifierValue[] = []
) {
super();
}
scope = this.provider.scope;
collection = this.provider.collection;
eventBus = this.provider.eventBus;
getRaw(
identifier: IdentifierValue,
{
sameScope = false,
optional = false,
noCache = false,
props,
}: ResolveOptions = {}
) {
const factory = this.provider.collection.getFactory(
identifier,
this.provider.scope
);
if (!factory) {
if (this.provider.parent && !sameScope) {
return this.provider.parent.getRaw(identifier, {
sameScope: sameScope,
optional,
noCache,
props,
});
}
if (optional) {
return undefined;
}
throw new ComponentNotFoundError(identifier);
}
const runFactory = () => {
const nextResolver = this.track(identifier);
try {
return withContext(() => factory(nextResolver), {
provider: this.provider,
props,
});
} catch (err) {
if (err instanceof ComponentNotFoundError) {
throw new MissingDependencyError(
identifier,
err.identifier,
this.stack
);
}
throw err;
}
};
if (noCache) {
return runFactory();
}
return this.provider.cache.getOrInsert(identifier, runFactory);
}
getAllRaw(
identifier: IdentifierValue,
{ sameScope = false, noCache, props }: ResolveOptions = {}
): Map<ComponentVariant, any> {
const vars = this.provider.collection.getFactoryAll(
identifier,
this.provider.scope
);
if (vars === undefined) {
if (this.provider.parent && !sameScope) {
return this.provider.parent.getAllRaw(identifier);
}
return new Map();
}
const result = new Map<ComponentVariant, any>();
for (const [variant, factory] of vars) {
// oxlint-disable-next-line sonarjs/no-identical-functions
const runFactory = () => {
const nextResolver = this.track(identifier);
try {
return withContext(() => factory(nextResolver), {
provider: this.provider,
props,
});
} catch (err) {
if (err instanceof ComponentNotFoundError) {
throw new MissingDependencyError(
identifier,
err.identifier,
this.stack
);
}
throw err;
}
};
let service;
if (noCache) {
service = runFactory();
} else {
service = this.provider.cache.getOrInsert(
{
identifierName: identifier.identifierName,
variant,
},
runFactory
);
}
result.set(variant, service);
}
return result;
}
track(identifier: IdentifierValue): Resolver {
const depth = this.depth + 1;
if (depth >= 100) {
throw new RecursionLimitError();
}
const circular = this.stack.find(
i =>
i.identifierName === identifier.identifierName &&
i.variant === identifier.variant
);
if (circular) {
throw new CircularDependencyError([...this.stack, identifier]);
}
return new Resolver(this.provider, depth, [...this.stack, identifier]);
}
override dispose(): void {}
}
export class BasicFrameworkProvider extends FrameworkProvider {
public readonly cache = new ComponentCachePool();
public readonly collection: Framework;
public readonly eventBus: EventBus;
disposed = false;
constructor(
collection: Framework,
public readonly scope: string[],
public readonly parent: FrameworkProvider | null
) {
super();
this.collection = collection;
this.eventBus = new EventBus(this, this.parent?.eventBus);
}
getRaw(identifier: IdentifierValue, options?: ResolveOptions) {
const resolver = new Resolver(this);
return resolver.getRaw(identifier, options);
}
getAllRaw(
identifier: IdentifierValue,
options?: ResolveOptions
): Map<ComponentVariant, any> {
const resolver = new Resolver(this);
return resolver.getAllRaw(identifier, options);
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.cache.dispose();
this.eventBus.dispose();
}
}
export class FrameworkStackProvider extends FrameworkProvider {
public readonly stack: FrameworkProvider[];
public readonly collection: Framework;
public readonly eventBus: EventBus;
constructor(providers: FrameworkProvider[]) {
if (providers.length === 0) {
throw new Error('FrameworkStackProvider must have at least one provider');
}
super();
this.stack = [...providers];
// use the collection and eventBus from the first provider
this.collection = this.stack[0].collection;
this.eventBus = this.stack[0].eventBus;
}
get scope(): FrameworkScopeStack {
return this.stack[0]?.scope || [];
}
getRaw(identifier: IdentifierValue, options?: ResolveOptions): any {
for (const provider of this.stack) {
const service = provider.getRaw(identifier, {
...options,
optional: true,
});
if (service) {
return service;
}
}
if (options?.optional) {
return undefined;
}
throw new ComponentNotFoundError(identifier);
}
getAllRaw(
identifier: IdentifierValue,
options?: ResolveOptions
): Map<ComponentVariant, any> {
for (const provider of this.stack) {
const components = provider.getAllRaw(identifier, options);
if (components.size > 0) {
return components;
}
}
return new Map();
}
dispose(): void {
// No need to handle the disposal of providers in the stack, as they are passed in externally
}
}

View File

@@ -0,0 +1,5 @@
import type { FrameworkScopeStack } from './types';
export function stringifyScope(scope: FrameworkScopeStack): string {
return scope.join('/');
}

View File

@@ -0,0 +1,35 @@
import type { FrameworkProvider } from './provider';
export type Type<T = any> = abstract new (...args: any) => T;
export type ComponentFactory<T = any> = (provider: FrameworkProvider) => T;
export type ComponentVariant = string;
export type FrameworkScopeStack = string[];
export type IdentifierValue = {
identifierName: string;
variant: ComponentVariant;
};
export type GeneralIdentifier<T = any> = Identifier<T> | Type<T>;
export type Identifier<T> = {
identifierName: string;
variant: ComponentVariant;
__TYPE__: T;
};
export type IdentifierType<T> =
T extends Identifier<infer R> ? R : T extends Type<infer R> ? R : never;
export type TypesToDeps<T> = {
[index in keyof T]:
| GeneralIdentifier<T[index]>
| (T[index] extends (infer I)[] ? [GeneralIdentifier<I>] : never);
};
export type SubComponent = {
identifier: Identifier<any>;
factory: ComponentFactory;
};

View File

@@ -0,0 +1,2 @@
export * from './core';
export * from './react';

View File

@@ -0,0 +1,81 @@
import React, { useContext, useMemo } from 'react';
import type { FrameworkProvider, Scope, Service } from '../core';
import { Framework, FrameworkStackProvider } from '../core';
import type { GeneralIdentifier, IdentifierType, Type } from '../core/types';
export const FrameworkProviderContext = React.createContext<FrameworkProvider>(
Framework.EMPTY.provider()
);
export function useFramework(): FrameworkProvider {
return useContext(FrameworkProviderContext); // never null, because the default value
}
export function useService<T>(identifier: GeneralIdentifier<T>): T {
return useContext(FrameworkProviderContext).get(identifier);
}
/**
* Hook to get services from the current framework stack.
*
* Automatically converts the service name to camelCase.
*
* @example
* ```ts
* const { authService, userService } = useServices({ AuthService, UserService });
* ```
*/
export function useServices<
const T extends { [key in string]: GeneralIdentifier<Service> },
>(
identifiers: T
): keyof T extends string
? { [key in Uncapitalize<keyof T>]: IdentifierType<T[Capitalize<key>]> }
: never {
const provider = useContext(FrameworkProviderContext);
const services: any = {};
for (const [key, value] of Object.entries(identifiers)) {
services[key.charAt(0).toLowerCase() + key.slice(1)] = provider.get(value);
}
return services;
}
export function useServiceOptional<T extends Service>(
identifier: Type<T>
): T | undefined {
return useContext(FrameworkProviderContext).getOptional(identifier);
}
export const FrameworkRoot = ({
framework,
children,
}: React.PropsWithChildren<{ framework: FrameworkProvider }>) => {
return (
<FrameworkProviderContext.Provider value={framework}>
{children}
</FrameworkProviderContext.Provider>
);
};
export const FrameworkScope = ({
scope,
children,
}: React.PropsWithChildren<{ scope?: Scope }>) => {
const provider = useContext(FrameworkProviderContext);
const nextStack = useMemo(() => {
if (!scope) return provider;
// make sure the stack order is inside to outside
return new FrameworkStackProvider([scope.framework, provider]);
}, [scope, provider]);
return (
<FrameworkProviderContext.Provider value={nextStack}>
{children}
</FrameworkProviderContext.Provider>
);
};

View File

@@ -0,0 +1,8 @@
export * from './app-config-storage';
export * from './atom';
export * from './framework';
export * from './livedata';
export * from './media';
export * from './orm';
export * from './storage';
export * from './utils';

View File

@@ -0,0 +1,337 @@
import type { Subscriber } from 'rxjs';
import { combineLatest, Observable, of } from 'rxjs';
import { describe, expect, test, vitest } from 'vitest';
import { LiveData, PoisonedError } from '..';
describe('livedata', () => {
test('LiveData', async () => {
const livedata$ = new LiveData(0);
expect(livedata$.value).toBe(0);
livedata$.next(1);
expect(livedata$.value).toBe(1);
let subscribed = 0;
livedata$.subscribe(v => {
subscribed = v;
});
livedata$.next(2);
expect(livedata$.value).toBe(2);
await vitest.waitFor(() => subscribed === 2);
});
test('from', async () => {
{
const livedata$ = LiveData.from(of(1, 2, 3, 4), 0);
expect(livedata$.value).toBe(4);
}
{
let subscriber: Subscriber<number> = null!;
const observable$ = new Observable<number>(s => {
subscriber = s;
});
const livedata$ = LiveData.from(observable$, 0);
let value = 0;
livedata$.subscribe(v => {
value = v;
});
expect(value).toBe(0);
subscriber.next(1);
expect(value).toBe(1);
subscriber.next(2);
expect(value).toBe(2);
}
{
let observableSubscribed = false;
let observableClosed = false;
const observable$ = new Observable(subscriber => {
observableSubscribed = true;
subscriber.next(1);
return () => {
observableClosed = true;
};
});
const livedata$ = LiveData.from(observable$, 0);
expect(observableSubscribed).toBe(false);
const subscription = livedata$.subscribe(_ => {});
expect(observableSubscribed).toBe(true);
expect(observableClosed).toBe(false);
subscription.unsubscribe();
expect(observableClosed).toBe(true);
}
{
let subscriber: Subscriber<number> = null!;
const observable$ = new Observable<number>(s => {
subscriber = s;
});
const livedata$ = LiveData.from(observable$, 0);
let value1 = 0;
livedata$.subscribe(v => {
value1 = v;
});
let value2 = 0;
livedata$.subscribe(v => {
value2 = v;
});
expect(value1).toBe(0);
expect(value2).toBe(0);
subscriber.next(1);
expect(value1).toBe(1);
expect(value2).toBe(1);
subscriber.next(2);
expect(value1).toBe(2);
expect(value2).toBe(2);
}
{
let observableSubscribed = false;
let observableClosed = false;
const observable$ = new Observable(subscriber => {
observableSubscribed = true;
subscriber.next(1);
return () => {
observableClosed = true;
};
});
const livedata$ = LiveData.from(observable$, 0);
expect(observableSubscribed).toBe(false);
const subscription1 = livedata$.subscribe(_ => {});
const subscription2 = livedata$.subscribe(_ => {});
expect(observableSubscribed).toBe(true);
expect(observableClosed).toBe(false);
subscription1.unsubscribe();
expect(observableClosed).toBe(false);
subscription2.unsubscribe();
expect(observableClosed).toBe(true);
}
{
let observerCount = 0;
const observable$ = new Observable(_ => {
observerCount++;
});
const livedata$ = LiveData.from(observable$, 0);
livedata$.subscribe(_ => {});
livedata$.subscribe(_ => {});
expect(observerCount).toBe(1);
}
{
let value = 0;
const observable$ = new Observable<number>(subscriber => {
subscriber.next(value);
});
const livedata$ = LiveData.from(observable$, 0);
expect(livedata$.value).toBe(0);
value = 1;
expect(livedata$.value).toBe(1);
}
});
test('poisoned', () => {
{
let subscriber: Subscriber<number> = null!;
const livedata$ = LiveData.from<number>(
new Observable(sub => {
subscriber = sub;
}),
1
);
let value: number = 0;
let error: any = null;
livedata$.subscribe({
next: v => {
value = v;
},
error: e => {
error = e;
},
});
expect(value).toBe(1);
subscriber.next(2);
expect(value).toBe(2);
expect(error).toBe(null);
subscriber.error('error');
expect(error).toBeInstanceOf(PoisonedError);
expect(() => livedata$.next(3)).toThrowError(PoisonedError);
expect(() => livedata$.value).toThrowError(PoisonedError);
let error2: any = null;
livedata$.subscribe({
error: e => {
error2 = e;
},
});
expect(error2).toBeInstanceOf(PoisonedError);
}
});
test('map', () => {
{
const livedata$ = new LiveData(0);
const mapped$ = livedata$.map(v => v + 1);
expect(mapped$.value).toBe(1);
livedata$.next(1);
expect(mapped$.value).toBe(2);
}
{
const livedata$ = new LiveData(0);
const mapped$ = livedata$.map(v => v + 1);
let value = 0;
mapped$.subscribe(v => {
value = v;
});
expect(value).toBe(1);
livedata$.next(1);
expect(value).toBe(2);
}
{
let observableSubscribed = false;
let observableClosed = false;
const observable$ = new Observable<number>(subscriber => {
observableSubscribed = true;
subscriber.next(1);
return () => {
observableClosed = true;
};
});
const livedata$ = LiveData.from(observable$, 0);
const mapped$ = livedata$.map(v => v + 1);
expect(observableSubscribed).toBe(false);
const subscription = mapped$.subscribe(_ => {});
expect(observableSubscribed).toBe(true);
expect(observableClosed).toBe(false);
subscription.unsubscribe();
expect(observableClosed).toBe(true);
}
});
test('interop with rxjs', () => {
const ob$ = combineLatest([new LiveData(1)]);
let value = 0;
ob$.subscribe(v => {
value = v[0];
});
expect(value).toBe(1);
});
test('flat', () => {
{
const wrapped$ = new LiveData(new LiveData(0));
const flatten$ = wrapped$.flat();
expect(flatten$.value).toBe(0);
wrapped$.next(new LiveData(1));
expect(flatten$.value).toBe(1);
wrapped$.next(LiveData.from(of(2, 3), 0));
expect(flatten$.value).toBe(3);
}
{
const wrapped$ = new LiveData(
new LiveData([
new LiveData(new LiveData(1)),
new LiveData(new LiveData(2)),
])
);
const flatten$ = wrapped$.flat();
expect(flatten$.value).toStrictEqual([1, 2]);
}
{
const wrapped$ = new LiveData([new LiveData(0), new LiveData(1)]);
const flatten$ = wrapped$.flat();
expect(flatten$.value).toEqual([0, 1]);
const inner$ = new LiveData(2);
wrapped$.next([inner$, new LiveData(3)]);
expect(flatten$.value).toEqual([2, 3]);
inner$.next(4);
expect(flatten$.value).toEqual([4, 3]);
}
{
const wrapped$ = new LiveData([] as LiveData<number>[]);
const flatten$ = wrapped$.flat();
expect(flatten$.value).toEqual([]);
}
});
test('computed', () => {
{
const a$ = new LiveData(1);
const b$ = LiveData.computed(get => get(a$) + 1);
expect(b$.value).toBe(2);
}
{
const a$ = new LiveData('v1');
const v1$ = new LiveData(100);
const v2$ = new LiveData(200);
const v$ = LiveData.computed(get => {
return get(a$) === 'v1' ? get(v1$) : get(v2$);
});
expect(v$.value).toBe(100);
a$.next('v2');
expect(v$.value).toBe(200);
}
{
let watched = false;
let count = 0;
let subscriber: Subscriber<number> = null!;
const a$ = LiveData.from<number>(
new Observable(sub => {
count++;
watched = true;
subscriber = sub;
sub.next(1);
return () => {
watched = false;
};
}),
0
);
const b$ = LiveData.computed(get => get(a$) + 1);
expect(watched).toBe(false);
expect(count).toBe(0);
const subscription = b$.subscribe(_ => {});
expect(watched).toBe(true);
expect(count).toBe(1);
subscriber.next(2);
expect(b$.value).toBe(3);
subscription.unsubscribe();
expect(watched).toBe(false);
expect(count).toBe(1);
}
{
let c$ = null! as LiveData<number>;
const b$ = LiveData.computed(get => get(c$) + 1);
c$ = LiveData.computed(get => get(b$) + 1);
expect(() => b$.value).toThrowError(PoisonedError);
}
});
});

View File

@@ -0,0 +1,183 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { useMemo } from 'react';
import type { Subscriber } from 'rxjs';
import { Observable } from 'rxjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { LiveData, useLiveData } from '..';
describe('livedata', () => {
afterEach(() => {
cleanup();
});
test('react', async () => {
const livedata$ = new LiveData(0);
let renderCount = 0;
const Component = () => {
renderCount++;
const value = useLiveData(livedata$);
return <main>{value}</main>;
};
render(<Component />);
expect(screen.getByRole('main').innerText).toBe('0');
livedata$.next(1);
// wait for rerender
await waitFor(() => expect(screen.getByRole('main').innerText).toBe('1'));
expect(renderCount).toBe(2);
});
test('react livedata.map', async () => {
const livedata$ = new LiveData(0);
let renderCount = 0;
const Component = () => {
renderCount++;
const value = useLiveData(livedata$.map(v => v + 1));
return <main>{value}</main>;
};
render(<Component />);
expect(screen.getByRole('main').innerText).toBe('1');
livedata$.next(1);
// wait for rerender
await waitFor(() => expect(screen.getByRole('main').innerText).toBe('2'));
expect(renderCount).toBe(2);
});
test('react livedata.map heavy object copy', async () => {
const livedata$ = new LiveData({ hello: 'world' });
let renderCount = 0;
let objectCopyCount = 0;
const Component = () => {
renderCount++;
const value = useLiveData(
livedata$.map(v => {
objectCopyCount++;
return { ...v };
})
);
return <main>{value.hello}</main>;
};
const { rerender } = render(<Component />);
expect(screen.getByRole('main').innerText).toBe('world');
livedata$.next({ hello: 'foobar' });
// wait for rerender
await waitFor(() =>
expect(screen.getByRole('main').innerText).toBe('foobar')
);
expect(renderCount).toBe(2);
expect(objectCopyCount).toBe(3);
rerender(<Component />);
expect(renderCount).toBe(3);
expect(objectCopyCount).toBe(4);
});
test('react useMemo livedata.map heavy object copy', async () => {
const livedata$ = new LiveData({ hello: 'world' });
let renderCount = 0;
let objectCopyCount = 0;
const Component = () => {
renderCount++;
const value = useLiveData(
useMemo(
() =>
livedata$.map(v => {
objectCopyCount++;
return { ...v };
}),
[]
)
);
return <main>{value.hello}</main>;
};
const { rerender } = render(<Component />);
expect(screen.getByRole('main').innerText).toBe('world');
livedata$.next({ hello: 'foobar' });
// wait for rerender
await waitFor(() =>
expect(screen.getByRole('main').innerText).toBe('foobar')
);
expect(renderCount).toBe(2);
expect(objectCopyCount).toBe(2);
rerender(<Component />);
expect(renderCount).toBe(3);
expect(objectCopyCount).toBe(2);
});
test('react useLiveData with livedata from observable', async () => {
let subscribeCount = 0;
let renderCount = 0;
let innerSubscriber: Subscriber<{
value: number;
}> = null!;
const livedata$ = LiveData.from(
new Observable<{ value: number }>(subscriber => {
subscribeCount++;
subscriber.next({ value: 1 });
innerSubscriber = subscriber;
}),
{ value: 0 }
);
const Component = () => {
renderCount++;
const value = useLiveData(
livedata$.map(v => ({
value: v.value + 1,
}))
).value;
return <main>{value}</main>;
};
const { rerender } = render(<Component />);
expect(screen.getByRole('main').innerText).toBe('2');
expect(subscribeCount).toBe(1);
expect(renderCount).toBe(1);
innerSubscriber.next({ value: 2 });
await waitFor(() => expect(screen.getByRole('main').innerText).toBe('3'));
expect(subscribeCount).toBe(1);
expect(renderCount).toBe(2);
rerender(<Component />);
expect(subscribeCount).toBe(1);
expect(renderCount).toBe(3);
});
test('lifecycle', async () => {
let observableSubscribed = false;
let observableClosed = false;
const observable$ = new Observable<number>(subscriber => {
observableSubscribed = true;
subscriber.next(1);
return () => {
observableClosed = true;
};
});
const livedata$ = LiveData.from(observable$, 0);
const Component1 = () => {
const value = useLiveData(livedata$);
return <main>{value}</main>;
};
expect(observableSubscribed).toBe(false);
const { rerender } = render(<Component1 />);
expect(observableSubscribed).toBe(true);
expect(observableClosed).toBe(false);
const Component2 = () => {
return <main></main>;
};
rerender(<Component2 />);
await vi.waitUntil(() => observableClosed);
});
});

View File

@@ -0,0 +1,119 @@
import type { Subscriber } from 'rxjs';
import { from, Observable, switchMap } from 'rxjs';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
catchErrorInto,
effect,
LiveData,
mapInto,
onComplete,
onStart,
} from '../../';
describe('example', () => {
type User = {
id: number;
name: string;
};
const fetchUser = vi.fn<(id: number) => Promise<User>>();
const user$ = new LiveData<User | null>(null);
const isLoading$ = new LiveData<boolean>(false);
const error$ = new LiveData<Error | null>(null);
const loadUser = effect(
switchMap((id: number) =>
from(fetchUser(id)).pipe(
mapInto(user$),
catchErrorInto(error$),
onStart(() => isLoading$.next(true)),
onComplete(() => isLoading$.next(false))
)
)
);
beforeEach(() => {
fetchUser.mockClear();
user$.next(null);
isLoading$.next(false);
error$.next(null);
});
test('basic', async () => {
fetchUser.mockImplementation(async id => ({ id, name: 'John' }));
loadUser(1);
await vi.waitFor(() =>
expect(user$.value).toStrictEqual({ id: 1, name: 'John' })
);
expect(fetchUser).toHaveBeenCalledOnce();
expect(fetchUser).toHaveBeenCalledWith(1);
});
test('error', async () => {
fetchUser.mockRejectedValue(new Error('some error'));
loadUser(1);
await vi.waitFor(() => expect(error$.value).toBeInstanceOf(Error));
await vi.waitFor(() => expect(isLoading$.value).toBe(false));
});
test('isLoading', async () => {
let resolveFn: (value: User) => void = null!;
fetchUser.mockReturnValue(
new Promise(resolve => {
resolveFn = resolve;
})
);
loadUser(1);
await vi.waitFor(() => expect(isLoading$.value).toBe(true));
expect(fetchUser).toHaveBeenCalledOnce();
resolveFn({ id: 1, name: 'John' });
await vi.waitFor(() => expect(isLoading$.value).toBe(false));
});
test('switchMap', async () => {
let fetch1: Subscriber<User> = null!;
let fetch1Canceled = false;
fetchUser.mockReturnValue(
new Observable<User>(subscriber => {
fetch1 = subscriber;
return () => {
fetch1Canceled = true;
};
}) as any
);
loadUser(1);
await vi.waitFor(() => expect(fetch1).toBeTruthy());
expect(isLoading$.value).toBe(true);
// start fetch2, should cancel fetch1
let fetch2: Subscriber<User> = null!;
fetchUser.mockReturnValue(
new Observable<User>(subscriber => {
fetch2 = subscriber;
}) as any
);
loadUser(2);
await vi.waitFor(() => expect(fetch1Canceled).toBe(true));
expect(isLoading$.value).toBe(true);
// fetch1 fail, should not affect fetch2
fetch1.error(new Error('some error'));
expect(error$.value).toBe(null);
// make fetch2 complete
fetch2.next({ id: 2, name: 'John' });
fetch2.complete();
// should update user$ to fetch2 result
await vi.waitFor(() =>
expect(user$.value).toStrictEqual({ id: 2, name: 'John' })
);
// should not have error
expect(error$.value).toBe(null);
expect(isLoading$.value).toBe(false);
});
});

View File

@@ -0,0 +1,127 @@
import { DebugLogger } from '@affine/debug';
import { Unreachable } from '@affine/env/constant';
import { type OperatorFunction, Subject, type Subscription } from 'rxjs';
const logger = new DebugLogger('effect');
export type Effect<T> = (T | undefined extends T // hack to detect if T is unknown
? () => void
: (value: T) => void) & {
// unsubscribe effect, all ongoing effects will be cancelled.
unsubscribe: () => void;
// reset internal state, all ongoing effects will be cancelled.
reset: () => void;
};
/**
* Create an effect.
*
* `effect( op1, op2, op3, ... )`
*
* You can think of an effect as a pipeline. When the effect is called, argument will be sent to the pipeline,
* and the operators in the pipeline can be triggered.
*
*
*
* @example
* ```ts
* const loadUser = effect(
* switchMap((id: number) =>
* from(fetchUser(id)).pipe(
* mapInto(user$),
* catchErrorInto(error$),
* onStart(() => isLoading$.next(true)),
* onComplete(() => isLoading$.next(false))
* )
* )
* );
*
* // emit value to effect
* loadUser(1);
*
* // unsubscribe effect, will stop all ongoing processes
* loadUser.unsubscribe();
* ```
*/
export function effect<T, A>(op1: OperatorFunction<T, A>): Effect<T>;
export function effect<T, A, B>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>
): Effect<T>;
export function effect<T, A, B, C>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>
): Effect<T>;
export function effect<T, A, B, C, D>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>,
op4: OperatorFunction<C, D>
): Effect<T>;
export function effect<T, A, B, C, D, E>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>,
op4: OperatorFunction<C, D>,
op5: OperatorFunction<D, E>
): Effect<T>;
export function effect<T, A, B, C, D, E, F>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>,
op4: OperatorFunction<C, D>,
op5: OperatorFunction<D, E>,
op6: OperatorFunction<E, F>
): Effect<T>;
export function effect(...args: any[]) {
const subject$ = new Subject<any>();
const effectLocation = BUILD_CONFIG.debug
? `(${new Error().stack?.split('\n')[2].trim()})`
: '';
class EffectError extends Unreachable {
constructor(message: string, value?: any) {
logger.error(`effect ${effectLocation} ${message}`, value);
super(
`effect ${effectLocation} ${message}` +
` ${value ? (value instanceof Error ? (value.stack ?? value.message) : value + '') : ''}`
);
}
}
let subscription: Subscription | null = null;
function subscribe() {
subscription = subject$.pipe.apply(subject$, args as any).subscribe({
complete() {
const error = new EffectError('effect unexpected complete');
// make a uncaught exception
setTimeout(() => {
throw error;
}, 0);
},
error(error) {
const effectError = new EffectError('effect uncaught error', error);
// make a uncaught exception
setTimeout(() => {
throw effectError;
}, 0);
},
});
}
subscribe();
const fn = (value: unknown) => {
subject$.next(value);
};
fn.unsubscribe = () => subscription?.unsubscribe();
fn.reset = () => {
subscription?.unsubscribe();
subscribe();
};
return fn as never;
}

View File

@@ -0,0 +1,13 @@
export { type Effect, effect } from './effect';
export { LiveData, PoisonedError } from './livedata';
export {
backoffRetry,
catchErrorInto,
exhaustMapSwitchUntilChanged,
fromPromise,
mapInto,
onComplete,
onStart,
smartRetry,
} from './ops';
export { useLiveData } from './react';

View File

@@ -0,0 +1,593 @@
import { DebugLogger } from '@affine/debug';
import { type ReadonlySignal, type Signal, signal } from '@preact/signals-core';
import type {
InteropObservable,
Observer,
OperatorFunction,
Subscription,
TeardownLogic,
ThrottleConfig,
} from 'rxjs';
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
EMPTY,
map,
mergeMap,
Observable,
of,
scan,
skip,
Subject,
switchMap,
throttleTime,
} from 'rxjs';
import { shallowEqual } from '../utils/shallow-equal';
const logger = new DebugLogger('livedata');
/**
* LiveData is a reactive data type.
*
* ## basic usage
*
* @example
* ```ts
* const livedata = new LiveData(0); // create livedata with initial value
*
* livedata.next(1); // update value
*
* console.log(livedata.value); // get current value
*
* livedata.subscribe(v => { // subscribe to value changes
* console.log(v); // 1
* });
* ```
*
* ## observable
*
* LiveData is a rxjs observable, you can use rxjs operators.
*
* @example
* ```ts
* new LiveData(0).pipe(
* map(v => v + 1),
* filter(v => v > 1),
* ...
* )
* ```
*
* NOTICE: different from normal observable, LiveData will always emit the latest value when you subscribe to it.
*
* ## from observable
*
* LiveData can be created from observable or from other livedata.
*
* @example
* ```ts
* const A = LiveData.from(
* of(1, 2, 3, 4), // from observable
* 0 // initial value
* );
*
* const B = LiveData.from(
* A.pipe(map(v => 'from a ' + v)), // from other livedata
* '' // initial value
* );
* ```
*
* ## Why is it called LiveData
*
* This API is very similar to LiveData in Android, as both are based on Observable, so I named it LiveData.
*
* @see {@link https://rxjs.dev/api/index/class/BehaviorSubject}
* @see {@link https://developer.android.com/topic/libraries/architecture/livedata}
*/
export class LiveData<T = unknown>
extends Observable<T>
implements InteropObservable<T>
{
static from<T>(
upstream$:
| Observable<T>
| InteropObservable<T>
| ((stream: Observable<LiveDataOperation>) => Observable<T>),
initialValue: T
): LiveData<T> {
const data$ = new LiveData(
initialValue,
typeof upstream$ === 'function'
? upstream$
: stream$ =>
stream$.pipe(
mergeMap(v => {
if (v === 'set') {
return EMPTY;
} else if (v === 'get') {
return of('watch' as const, 'unwatch' as const);
} else {
return of(v);
}
}),
scan((acc, op) => {
if (op === 'watch') {
return acc + 1;
} else if (op === 'unwatch') {
return acc - 1;
} else {
return acc;
}
}, 0),
map(count => {
if (count > 0) {
return 'watch';
} else {
return 'unwatch';
}
}),
distinctUntilChanged(),
switchMap(op => {
if (op === 'watch') {
return upstream$;
} else {
return EMPTY;
}
})
)
);
return data$;
}
static fromSignal<T>(signal: ReadonlySignal<T>): LiveData<T> {
return LiveData.from(
new Observable(subscriber => {
const unsubscribe = signal.subscribe(value => {
subscriber.next(value);
});
return () => {
unsubscribe();
};
}),
signal.value
);
}
private static GLOBAL_COMPUTED_RECURSIVE_COUNT = 0;
/**
* @example
* ```ts
* const a = new LiveData('v1');
* const v1 = new LiveData(100);
* const v2 = new LiveData(200);
*
* const v = LiveData.computed(get => {
* return get(a) === 'v1' ? get(v1) : get(v2);
* });
*
* expect(v.value).toBe(100);
* ```
*/
static computed<T>(
compute: (get: <L>(data: LiveData<L>) => L) => T
): LiveData<T> {
return LiveData.from(
new Observable(subscribe => {
const execute = (next: () => void) => {
const subscriptions: Subscription[] = [];
const getfn = <L>(data$: LiveData<L>) => {
let value = null as L;
let first = true;
subscriptions.push(
data$.subscribe({
error(err) {
subscribe.error(err);
},
next(v) {
value = v;
if (!first) {
next();
}
first = false;
},
})
);
return value;
};
LiveData.GLOBAL_COMPUTED_RECURSIVE_COUNT++;
try {
if (LiveData.GLOBAL_COMPUTED_RECURSIVE_COUNT > 10) {
subscribe.error(new Error('computed recursive limit exceeded'));
} else {
subscribe.next(compute(getfn));
}
} catch (err) {
subscribe.error(err);
} finally {
LiveData.GLOBAL_COMPUTED_RECURSIVE_COUNT--;
}
return () => {
subscriptions.forEach(s => s.unsubscribe());
};
};
let prev = () => {};
const looper = () => {
const dispose = execute(looper);
prev();
prev = dispose;
};
looper();
return () => {
prev();
};
}),
null as any
);
}
private readonly raw$: BehaviorSubject<T>;
private readonly ops$ = new Subject<LiveDataOperation>();
private readonly upstreamSubscription: Subscription | undefined;
/**
* When the upstream Observable of livedata throws an error, livedata will enter poisoned state. This is an
* unrecoverable abnormal state. Any operation on livedata will throw a PoisonedError.
*
* Since the development specification for livedata is not to throw any error, entering the poisoned state usually
* means a programming error.
*/
private isPoisoned = false;
private poisonedError: PoisonedError | null = null;
private _signal: Signal<T> | undefined;
private _signalSubscription: Subscription | undefined;
constructor(
initialValue: T,
upstream?: (upstream: Observable<LiveDataOperation>) => Observable<T>
) {
super();
this.raw$ = new BehaviorSubject(initialValue);
if (upstream) {
this.upstreamSubscription = upstream(this.ops$).subscribe({
next: v => {
this.raw$.next(v);
},
complete: () => {
if (!this.raw$.closed) {
logger.error('livedata upstream unexpected complete');
}
},
error: err => {
logger.error('uncatched error in livedata', err);
this.isPoisoned = true;
this.poisonedError = new PoisonedError(err);
this.raw$.error(this.poisonedError);
},
});
}
}
getValue = (): T => {
if (this.isPoisoned) {
throw this.poisonedError;
}
this.ops$.next('get');
return this.raw$.value;
};
setValue = (v: T) => {
if (this.isPoisoned) {
throw this.poisonedError;
}
this.raw$.next(v);
this.ops$.next('set');
};
get value(): T {
return this.getValue();
}
set value(v: T) {
this.next(v);
}
get signal(): ReadonlySignal<T> {
if (!this._signal) {
this._signal = signal(this.value);
this._signalSubscription = this.subscribe(v => {
// oxlint-disable-next-line no-non-null-assertion
this._signal!.value = v;
});
}
return this._signal;
}
next = (v: T) => {
if (this.isPoisoned) {
throw this.poisonedError;
}
return this.setValue(v);
};
override subscribe(
observerOrNext?: Partial<Observer<T>> | ((value: T) => void)
): Subscription;
override subscribe(
next?: ((value: T) => void) | null,
error?: ((error: any) => void) | null,
complete?: (() => void) | null
): Subscription;
override subscribe(
observerOrNext?: Partial<Observer<T>> | ((value: T) => void) | null,
error?: ((error: any) => void) | null,
complete?: (() => void) | null
): Subscription {
this.ops$.next('watch');
const subscription = this.raw$.subscribe(
observerOrNext as any,
error,
complete
);
subscription.add(() => {
this.ops$.next('unwatch');
});
return subscription;
}
map<R>(mapper: (v: T) => R): LiveData<R> {
const sub$ = LiveData.from(
new Observable<R>(subscriber =>
this.subscribe({
next: v => {
subscriber.next(mapper(v));
},
complete: () => {
sub$.complete();
},
})
),
undefined as R // is safe
);
return sub$;
}
/**
* same as map, but do shallow equal check before emit
*/
selector<R>(selector: (v: T) => R): LiveData<R> {
const sub$ = LiveData.from(
new Observable<R>(subscriber => {
let last: any = undefined;
return this.subscribe({
next: v => {
const data = selector(v);
if (!shallowEqual(last, data)) {
subscriber.next(data);
}
last = data;
},
complete: () => {
sub$.complete();
},
});
}),
undefined as R // is safe
);
return sub$;
}
distinctUntilChanged(comparator?: (previous: T, current: T) => boolean) {
return LiveData.from(
this.pipe(distinctUntilChanged(comparator)),
null as T
);
}
throttleTime(
duration: number,
{ trailing = true, leading = true }: ThrottleConfig = {}
) {
return LiveData.from<T>(
this.pipe(throttleTime(duration, undefined, { trailing, leading })),
null as any
);
}
asObservable(): Observable<T> {
return new Observable<T>(subscriber => {
return this.subscribe(subscriber);
});
}
override pipe(): Observable<T>;
override pipe<A>(op1: OperatorFunction<T, A>): Observable<A>;
override pipe<A, B>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>
): Observable<B>;
override pipe<A, B, C>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>
): Observable<C>;
override pipe<A, B, C, D>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>,
op4: OperatorFunction<C, D>
): Observable<D>;
override pipe<A, B, C, D, E>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>,
op4: OperatorFunction<C, D>,
op5: OperatorFunction<D, E>
): Observable<E>;
override pipe<A, B, C, D, E, F>(
op1: OperatorFunction<T, A>,
op2: OperatorFunction<A, B>,
op3: OperatorFunction<B, C>,
op4: OperatorFunction<C, D>,
op5: OperatorFunction<D, E>,
op6: OperatorFunction<E, F>
): Observable<F>;
override pipe(...args: any[]) {
return new Observable(subscriber => {
this.ops$.next('watch');
const subscription = this.raw$.pipe
.apply(this.raw$, args as any)
.subscribe(subscriber);
subscription.add(() => {
this.ops$.next('unwatch');
});
return subscription;
});
}
complete() {
this.ops$.complete();
this.raw$.complete();
this.upstreamSubscription?.unsubscribe();
this._signalSubscription?.unsubscribe();
}
/**
* flatten the livedata
*
* ```
* new LiveData(new LiveData(0)).flat() // LiveData<number>
* ```
*
* ```
* new LiveData([new LiveData(0)]).flat() // LiveData<number[]>
* ```
*/
flat(): Flat<this> {
return LiveData.from(
this.pipe(
switchMap(v => {
if (v instanceof LiveData) {
return (v as LiveData<any>).flat();
} else if (Array.isArray(v)) {
if (v.length === 0) {
return of([]);
}
return combineLatest(
v.map(v => {
if (v instanceof LiveData) {
return v.flat();
} else {
return of(v);
}
})
);
} else {
return of(v);
}
})
),
null as any
) as any;
}
static flat<T>(v: T): Flat<LiveData<T>> {
return new LiveData(v).flat();
}
waitFor(predicate: (v: T) => unknown, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const subscription = this.subscribe(v => {
if (predicate(v)) {
resolve(v as any);
// oxlint-disable-next-line typescript/no-floating-promises
Promise.resolve().then(() => {
subscription.unsubscribe();
});
}
});
signal?.addEventListener('abort', reason => {
subscription.unsubscribe();
reject(reason);
});
});
}
waitForNonNull(signal?: AbortSignal) {
return this.waitFor(v => v !== null && v !== undefined, signal) as Promise<
NonNullable<T>
>;
}
reactSubscribe = (cb: () => void) => {
if (this.isPoisoned) {
throw this.poisonedError;
}
this.ops$.next('watch');
const subscription = this.raw$
.pipe(distinctUntilChanged(), skip(1))
.subscribe(cb);
subscription.add(() => {
this.ops$.next('unwatch');
});
return () => subscription.unsubscribe();
};
reactGetSnapshot = () => {
if (this.isPoisoned) {
throw this.poisonedError;
}
this.ops$.next('watch');
// oxlint-disable-next-line typescript/no-floating-promises -- never throw
Promise.resolve().then(() => {
this.ops$.next('unwatch');
});
return this.raw$.value;
};
protected _subscribe(): TeardownLogic {
throw new Error('Method not implemented.');
}
[Symbol.observable || '@@observable']() {
return this;
}
[Symbol.observable]() {
return this;
}
}
export type LiveDataOperation = 'set' | 'get' | 'watch' | 'unwatch';
export type Unwrap<T> =
T extends LiveData<infer Z>
? Unwrap<Z>
: T extends readonly [...infer Elements]
? { [K in keyof Elements]: Unwrap<Elements[K]> }
: T;
export type Flat<T> = T extends LiveData<infer P> ? LiveData<Unwrap<P>> : T;
export class PoisonedError extends Error {
constructor(originalError: any) {
super(
'The livedata is poisoned, original error: ' +
(originalError instanceof Error ? originalError.stack : originalError)
);
}
}

View File

@@ -0,0 +1,206 @@
import { UserFriendlyError } from '@affine/error';
import {
catchError,
connect,
distinctUntilChanged,
EMPTY,
exhaustMap,
merge,
mergeMap,
Observable,
type ObservableInput,
type ObservedValueOf,
of,
type OperatorFunction,
pipe,
retry,
switchMap,
throwError,
timer,
} from 'rxjs';
import { MANUALLY_STOP } from '../utils';
import type { LiveData } from './livedata';
/**
* An operator that maps the value to the `LiveData`.
*/
export function mapInto<T>(l$: LiveData<T>) {
return pipe(
mergeMap((value: T) => {
l$.next(value);
return EMPTY;
})
);
}
/**
* An operator that catches the error and sends it to the `LiveData`.
*
* The `LiveData` will be set to `null` when the observable completes. This is useful for error state recovery.
*
* @param cb A callback that will be called when an error occurs.
*/
export function catchErrorInto<Error = any>(
l$: LiveData<Error | null>,
cb?: (error: Error) => void
) {
return pipe(
onComplete(() => l$.next(null)),
catchError((error: any) => {
l$.next(error);
cb?.(error);
return EMPTY;
})
);
}
/**
* An operator that calls the callback when the observable starts.
*/
export function onStart<T>(cb: () => void): OperatorFunction<T, T> {
return observable$ =>
new Observable(subscribe => {
cb();
return observable$.subscribe(subscribe);
});
}
/**
* An operator that calls the callback when the observable completes.
*/
export function onComplete<T>(cb: () => void): OperatorFunction<T, T> {
return observable$ =>
new Observable(subscribe => {
return observable$.subscribe({
complete() {
cb();
subscribe.complete();
},
error(err) {
subscribe.error(err);
},
next(value) {
subscribe.next(value);
},
});
});
}
/**
* Convert a promise to an observable.
*
* like `from` but support `AbortSignal`.
*/
export function fromPromise<T>(
promise: Promise<T> | ((signal: AbortSignal) => Promise<T>)
): Observable<T> {
return new Observable(subscriber => {
const abortController = new AbortController();
const rawPromise =
promise instanceof Function ? promise(abortController.signal) : promise;
rawPromise
.then(value => {
subscriber.next(value);
subscriber.complete();
})
.catch(error => {
subscriber.error(error);
});
return () => abortController.abort(MANUALLY_STOP);
});
}
/**
* An operator that retries the source observable when an error occurs.
*
* https://en.wikipedia.org/wiki/Exponential_backoff
*/
export function backoffRetry<T>({
when,
count = 3,
delay = 200,
maxDelay = 15000,
}: {
when?: (err: any) => boolean;
count?: number;
delay?: number;
maxDelay?: number;
} = {}) {
return (obs$: Observable<T>) =>
obs$.pipe(
retry({
count,
delay: (err, retryIndex) => {
if (when && !when(err)) {
return throwError(() => err);
}
const d = Math.pow(2, retryIndex - 1) * delay;
return timer(Math.min(d, maxDelay));
},
})
);
}
export function smartRetry<T>({
count = 3,
delay = 200,
maxDelay = 15000,
}: {
count?: number;
delay?: number;
maxDelay?: number;
} = {}) {
return (obs$: Observable<T>) =>
obs$.pipe(
backoffRetry({
when: UserFriendlyError.isNetworkError,
count: Infinity,
delay,
maxDelay,
}),
backoffRetry({
when: UserFriendlyError.notNetworkError,
count,
delay,
maxDelay,
})
);
}
/**
* An operator that combines `exhaustMap` and `switchMap`.
*
* This operator executes the `comparator` on each input, acting as an `exhaustMap` when the `comparator` returns `true`
* and acting as a `switchMap` when the comparator returns `false`.
*
* It is more useful for async processes that are relatively stable in results but sensitive to input.
* For example, when requesting the user's subscription status, `exhaustMap` is used because the user's subscription
* does not change often, but when switching users, the request should be made immediately like `switchMap`.
*
* @param onSwitch callback will be executed when `switchMap` occurs (including the first execution).
*/
export function exhaustMapSwitchUntilChanged<T, O extends ObservableInput<any>>(
comparator: (previous: T, current: T) => boolean,
project: (value: T, index: number) => O,
onSwitch?: (value: T) => void
): OperatorFunction<T, ObservedValueOf<O>> {
return pipe(
connect(shared$ =>
shared$.pipe(
distinctUntilChanged(comparator),
switchMap(value => {
onSwitch?.(value);
return merge(of(value), shared$).pipe(
exhaustMap((value, index) => {
return project(value, index);
})
);
})
)
)
);
}

View File

@@ -0,0 +1,37 @@
import { useSyncExternalStore } from 'react';
import type { LiveData } from './livedata';
function noopSubscribe() {
return () => {};
}
function nullGetSnapshot() {
return null;
}
function undefinedGetSnapshot() {
return undefined;
}
/**
* subscribe LiveData and return the value.
*/
export function useLiveData<Input extends LiveData<any> | null | undefined>(
liveData: Input
): NonNullable<Input> extends LiveData<infer T>
? Input extends undefined
? T | undefined
: Input extends null
? T | null
: T
: never {
return useSyncExternalStore(
liveData ? liveData.reactSubscribe : noopSubscribe,
liveData
? liveData.reactGetSnapshot
: liveData === undefined
? undefinedGetSnapshot
: nullGetSnapshot
);
}

View File

@@ -0,0 +1 @@
export * from './types';

View File

@@ -0,0 +1,71 @@
/**
* Attachment block audio media.
* blockId/docId/workspaceId are used to identify the source of the media
* to control the exclusivity playback state of audio across the whole application.
*/
export interface AttachmentBlockAudioMedia {
blobId: string; // aka sourceId
blockId: string;
docId: string;
workspaceId: string;
}
export interface AudioMediaDescriptor {
key: AudioMediaKey;
tabId: string | null;
name: string;
size: number;
blobId: string; // aka sourceId
}
// workspaceId/docId/blockId/blobId
export type AudioMediaKey = `${string}/${string}/${string}/${string}`;
export const attachmentBlockAudioMediaKey = (
media: AttachmentBlockAudioMedia
): AudioMediaKey => {
return `${media.workspaceId}/${media.docId}/${media.blockId}/${media.blobId}`;
};
export const parseAudioMediaKey = (
key: AudioMediaKey
): AttachmentBlockAudioMedia => {
const [workspaceId, docId, blockId, blobId] = key.split('/');
return {
workspaceId,
docId,
blockId,
blobId,
};
};
type State = 'idle' | 'playing' | 'paused' | 'stopped';
export interface MediaStats {
key: AudioMediaKey;
tabId: string | null;
duration: number;
name: string;
size: number;
waveform: number[]; // for drawing waveform, maxmium of 1000 samples
}
export interface PlaybackState {
key: AudioMediaKey;
tabId: string | null;
state: State;
/**
* Whenever the user seek the media, the startSeekOffset will be updated
*/
seekOffset: number;
/**
* Whenever the media state is updated.
* the updateTime will be updated. It is in milliseconds (unix timestamp).
* The current playback position (0-based, in seconds) is calculated by `seekOffset + (Date.now() - updateTime) / 1000 * rate`
*/
updateTime: number;
/**
* the playback rate
*/
playbackRate: number;
}

View File

@@ -0,0 +1,159 @@
# Introduction
Operation Pattern is a tiny `RPC` framework available both in frontend and backend.
It introduces super simple call and listen signatures to make Worker, cross tabs SharedWorker or BroadcastChannel easier to use and reduce boilerplate.
# usage
## Register Op Handlers
### Function call handler
```ts
interface Ops extends OpSchema {
add: [{ a: number; b: number }, number]
}
// register
const consumer: OpConsumer<Ops>;
consumer.register('add', ({ a, b }) => a + b);
// call
const client: OpClient<Ops>;
const ret = client.call('add', { a: 1, b: 2 })); // Promise<3>
```
### Stream call handler
```ts
interface Ops extends OpSchema {
subscribeStatus: [number, string];
}
// register
const consumer: OpConsumer<Ops>;
consumer.register('subscribeStatus', (id: number) => {
return interval(3000).pipe(map(() => 'connected'));
});
// subscribe
const client: OpClient<Ops>;
client.ob$('subscribeStatus', 123).subscribe({
next: status => {
ui.setServerStatus(status);
},
error: error => {
ui.setServerError(error);
},
complete: () => {
//
},
});
```
### Transfer variables
> [Transferable Objects](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects)
#### Client transferables
```ts
interface Ops extends OpSchema {
heavyWork: [{ name: string; data: Uint8Array; data2: Uint8Array }, void];
}
const client: OpClient<Ops>;
const data = new Uint8Array([1, 2, 3]);
const nonTransferredData = new Uint8Array([1, 2, 3]);
client.call(
'heavyWork',
transfer(
{
name: '',
data: data,
data2: nonTransferredData,
},
[data.buffer]
)
);
// after transferring, you can not use the transferred variables anymore!!!
// moved
assertEq(data.byteLength, 0);
// copied
assertEq(nonTransferredData.byteLength, 3);
```
#### Consumer transferables
```ts
interface Ops extends OpSchema {
job: [{ id: string }, Uint8Array];
}
const consumer: OpConsumer<Ops>;
consumer.register('ops', ({ id }) => {
return interval(3000).pipe(
map(() => {
const data = new Uint8Array([1, 2, 3]);
transfer(data, [data.buffer]);
})
);
});
```
## Communication
### BroadcastChannel
:::CAUTION
BroadcastChannel doesn't support transfer transferable objects. All data passed through it's `postMessage` api would be structured cloned
see [Structured_clone_algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm)
:::
```ts
const channel = new BroadcastChannel('domain');
const consumer = new OpConsumer(channel);
consumer.listen();
const client = new OpClient(channel);
client.listen();
```
### MessageChannel
```ts
const { port1, port2 } = new MessageChannel();
const client = new OpClient(port1);
const consumer = new OpConsumer(port2);
```
### Worker
```ts
const worker = new Worker('./xxx-worker');
const client = new OpClient(worker);
// in worker
const consumer = new OpConsumer(globalThis);
consumer.listen();
```
### SharedWorker
```ts
const worker = new SharedWorker('./xxx-worker');
const client = new OpClient(worker.port);
// in worker
globalThis.addEventListener('connect', event => {
const port = event.ports[0];
const consumer = new OpConsumer(port);
consumer.listen();
});
```

View File

@@ -0,0 +1,300 @@
import { afterEach } from 'node:test';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OpClient } from '../client';
import { type MessageHandlers, transfer } from '../message';
import type { OpSchema } from '../types';
interface TestOps extends OpSchema {
add: [{ a: number; b: number }, number];
bin: [Uint8Array, Uint8Array];
sub: [Uint8Array, number];
init: [{ fastText?: boolean } | undefined, { ok: true }];
any: [undefined, unknown];
}
declare module 'vitest' {
interface TestContext {
producer: OpClient<TestOps>;
handlers: MessageHandlers;
postMessage: ReturnType<typeof vi.fn>;
}
}
describe('op client', () => {
beforeEach(ctx => {
const { port1 } = new MessageChannel();
// @ts-expect-error patch postMessage
port1.postMessage = vi.fn(port1.postMessage);
// @ts-expect-error patch postMessage
ctx.postMessage = port1.postMessage;
ctx.producer = new OpClient(port1, {
timeout: 1000,
});
// @ts-expect-error internal api
ctx.handlers = ctx.producer.handlers;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should send call op', async ctx => {
// @ts-expect-error internal api
const pendingCalls = ctx.producer.pendingCalls;
const result = ctx.producer.call('add', { a: 1, b: 2 });
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "add:1",
"name": "add",
"payload": {
"a": 1,
"b": 2,
},
"type": "call",
}
`);
expect(pendingCalls.has('add:1')).toBe(true);
// fake consumer return
ctx.handlers.return({ type: 'return', id: 'add:1', data: 3 });
await expect(result).resolves.toBe(3);
expect(pendingCalls.has('add:1')).toBe(false);
});
it('should transfer transferables with call op', async ctx => {
const data = new Uint8Array([1, 2, 3]);
const result = ctx.producer.call('bin', transfer(data, [data.buffer]));
expect(ctx.postMessage.mock.calls[0][1].transfer[0]).toBeInstanceOf(
ArrayBuffer
);
// fake consumer return
ctx.handlers.return({
type: 'return',
id: 'bin:1',
data: new Uint8Array([3, 2, 1]),
});
await expect(result).resolves.toEqual(new Uint8Array([3, 2, 1]));
expect(data.byteLength).toBe(0);
});
it.for([true, false])(
'should preserve payload with optional abort signal: %s',
async (withSignal, ctx) => {
const abortController = new AbortController();
const result = ctx.producer.call(
'init',
{ fastText: true },
withSignal ? abortController.signal : undefined
);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "init:1",
"name": "init",
"payload": {
"fastText": true,
},
"type": "call",
}
`);
ctx.handlers.return({
type: 'return',
id: 'init:1',
data: { ok: true },
});
await expect(result).resolves.toEqual({ ok: true });
}
);
it('should send undefined payload for optional input call', async ctx => {
const result = ctx.producer.call('init', undefined);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "init:1",
"name": "init",
"payload": undefined,
"type": "call",
}
`);
ctx.handlers.return({
type: 'return',
id: 'init:1',
data: { ok: true },
});
await expect(result).resolves.toEqual({ ok: true });
});
it('should cancel call', async ctx => {
const promise = ctx.producer.call('add', { a: 1, b: 2 });
promise.cancel();
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"id": "add:1",
"type": "cancel",
},
]
`);
await expect(promise).rejects.toThrow('canceled');
});
it('should timeout call', async ctx => {
const promise = ctx.producer.call('add', { a: 1, b: 2 });
vi.advanceTimersByTime(4000);
await expect(promise).rejects.toThrow('timeout');
});
it('should send subscribe op', async ctx => {
let ob = {
next: vi.fn(),
error: vi.fn(),
complete: vi.fn(),
};
// @ts-expect-error internal api
const subscriptions = ctx.producer.obs;
ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe(ob);
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"id": "sub:1",
"name": "sub",
"payload": Uint8Array [
1,
2,
3,
],
"type": "subscribe",
}
`);
expect(subscriptions.has('sub:1')).toBe(true);
// fake consumer return
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 1 });
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 2 });
ctx.handlers.next({ type: 'next', id: 'sub:1', data: 3 });
expect(subscriptions.has('sub:1')).toBe(true);
ctx.handlers.complete({ type: 'complete', id: 'sub:1' });
expect(ob.next).toHaveBeenCalledTimes(3);
expect(ob.complete).toHaveBeenCalledTimes(1);
expect(subscriptions.has('sub:1')).toBe(false);
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"id": "sub:1",
"type": "unsubscribe",
},
]
`);
// smoking
ob = {
next: vi.fn(),
error: vi.fn(),
complete: vi.fn(),
};
ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe(ob);
expect(subscriptions.has('sub:2')).toBe(true);
ctx.handlers.next({ type: 'next', id: 'sub:2', data: 1 });
ctx.handlers.error({
type: 'error',
id: 'sub:2',
error: new Error('test'),
});
expect(ob.next).toHaveBeenCalledTimes(1);
expect(ob.error).toHaveBeenCalledTimes(1);
expect(subscriptions.has('sub')).toBe(false);
});
it('should transfer transferables with subscribe op', async ctx => {
const data = new Uint8Array([1, 2, 3]);
const sub = ctx.producer
.ob$('bin', transfer(data, [data.buffer]))
.subscribe({
next: vi.fn(),
});
expect(data.byteLength).toBe(0);
sub.unsubscribe();
});
it('hydrates worker stacks for calls and observables', async ctx => {
const marker = 'WORKER_STACK_MARKER';
const remoteError = {
name: 'TypeError',
message: marker,
stacktrace: `TypeError: ${marker}\n at workerTask (worker.ts:42:7)`,
} as unknown as Error;
const call = ctx.producer.call('any', undefined);
ctx.handlers.return({
type: 'return',
id: 'any:1',
error: remoteError,
});
await expect(call).rejects.toMatchObject({
name: 'TypeError',
message: marker,
stack: expect.stringContaining('worker.ts:42:7'),
});
const observableError = new Promise<unknown>(resolve => {
ctx.producer.ob$('any').subscribe({ error: resolve });
});
ctx.handlers.error({
type: 'error',
id: 'any:2',
error: remoteError,
});
await expect(observableError).resolves.toMatchObject({
name: 'TypeError',
message: marker,
stack: expect.stringContaining('worker.ts:42:7'),
});
});
it('should unsubscribe subscription op', ctx => {
const sub = ctx.producer.ob$('sub', new Uint8Array([1, 2, 3])).subscribe({
next: vi.fn(),
});
sub.unsubscribe();
expect(ctx.postMessage.mock.lastCall).toMatchInlineSnapshot(`
[
{
"id": "sub:1",
"type": "unsubscribe",
},
]
`);
});
});

View File

@@ -0,0 +1,266 @@
import { afterEach } from 'node:test';
import { Observable } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OpConsumer } from '../consumer';
import { type MessageHandlers, transfer } from '../message';
import type { OpSchema } from '../types';
interface TestOps extends OpSchema {
add: [{ a: number; b: number }, number];
any: [any, any];
}
declare module 'vitest' {
interface TestContext {
consumer: OpConsumer<TestOps>;
handlers: MessageHandlers;
postMessage: ReturnType<typeof vi.fn>;
}
}
describe('op consumer', () => {
beforeEach(ctx => {
const { port2 } = new MessageChannel();
// @ts-expect-error patch postMessage
port2.postMessage = vi.fn(port2.postMessage);
// @ts-expect-error patch postMessage
ctx.postMessage = port2.postMessage;
ctx.consumer = new OpConsumer(port2);
// @ts-expect-error internal api
ctx.handlers = ctx.consumer.handlers;
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should throw if no handler registered', async ctx => {
ctx.handlers.call({ type: 'call', id: 'add:1', name: 'add', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.lastCall?.[0]).toMatchObject({
type: 'return',
id: 'add:1',
error: {
message: 'Handler for operation [add] is not registered.',
name: 'Error',
},
});
});
it('should handle call message', async ctx => {
ctx.consumer.register('add', ({ a, b }) => a + b);
ctx.handlers.call({
type: 'call',
id: 'add:1',
name: 'add',
payload: { a: 1, b: 2 },
});
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls[0][0]).toMatchInlineSnapshot(`
{
"data": 3,
"id": "add:1",
"type": "return",
}
`);
});
it('should serialize string errors with message', async ctx => {
ctx.consumer.register('any', () => {
throw 'worker panic';
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls[0][0]).toMatchObject({
type: 'return',
id: 'any:1',
error: {
name: 'Error',
message: 'worker panic',
},
});
});
it('should serialize plain object errors with fallback message', async ctx => {
ctx.consumer.register('any', () => {
throw { reason: 'panic', code: 'E_PANIC' };
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
const message = ctx.postMessage.mock.calls[0][0]?.error?.message;
expect(typeof message).toBe('string');
expect(message).toContain('"reason":"panic"');
expect(message).toContain('"code":"E_PANIC"');
});
it('serializes worker stacks for calls and observables', async ctx => {
for (const kind of ['call', 'observable'] as const) {
ctx.postMessage.mockClear();
const marker = 'WORKER_STACK_MARKER';
const error = new TypeError(marker);
error.stack = `TypeError: ${marker}\n at workerTask (worker.ts:42:7)`;
ctx.consumer.register('any', () => {
if (kind === 'observable') {
return new Observable(observer => observer.error(error));
}
throw error;
});
if (kind === 'observable') {
ctx.handlers.subscribe({
type: 'subscribe',
id: 'any:1',
name: 'any',
payload: undefined,
});
} else {
ctx.handlers.call({
type: 'call',
id: 'any:1',
name: 'any',
payload: undefined,
});
}
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls[0][0].error).toMatchObject({
name: 'TypeError',
message: marker,
stacktrace: expect.stringContaining('worker.ts:42:7'),
});
}
});
it('should handle cancel message', async ctx => {
ctx.consumer.register('add', ({ a, b }, { signal }) => {
const { reject, resolve, promise } = Promise.withResolvers<number>();
signal?.addEventListener('abort', () => {
reject(new Error('canceled'));
});
setTimeout(() => {
resolve(a + b);
}, Number.MAX_SAFE_INTEGER);
return promise;
});
ctx.handlers.call({
type: 'call',
id: 'add:1',
name: 'add',
payload: { a: 1, b: 2 },
});
ctx.handlers.cancel({ type: 'cancel', id: 'add:1' });
await vi.advanceTimersByTimeAsync(1);
expect(ctx.postMessage).not.toBeCalled();
});
it('should transfer transferables in return', async ctx => {
const data = new Uint8Array([1, 2, 3]);
const nonTransferred = new Uint8Array([4, 5, 6]);
ctx.consumer.register('any', () => {
return transfer({ data: { data, nonTransferred } }, [data.buffer]);
});
ctx.handlers.call({ type: 'call', id: 'any:1', name: 'any', payload: {} });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage).toHaveBeenCalledOnce();
expect(data.byteLength).toBe(0);
expect(nonTransferred.byteLength).toBe(3);
});
it('should handle subscribe message', async ctx => {
ctx.consumer.register('any', data => {
return new Observable(observer => {
data.forEach((v: number) => observer.next(v));
observer.complete();
});
});
ctx.handlers.subscribe({
type: 'subscribe',
id: 'any:1',
name: 'any',
payload: transfer(new Uint8Array([1, 2, 3]), [
new Uint8Array([1, 2, 3]).buffer,
]),
});
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls.map(call => call[0]))
.toMatchInlineSnapshot(`
[
{
"data": 1,
"id": "any:1",
"type": "next",
},
{
"data": 2,
"id": "any:1",
"type": "next",
},
{
"data": 3,
"id": "any:1",
"type": "next",
},
{
"id": "any:1",
"type": "complete",
},
]
`);
});
it('should handle unsubscribe message', async ctx => {
ctx.consumer.register('any', data => {
return new Observable(observer => {
data.forEach((v: number) => {
setTimeout(() => {
observer.next(v);
}, 1);
});
setTimeout(() => {
observer.complete();
}, 1);
});
});
ctx.handlers.subscribe({
type: 'subscribe',
id: 'any:1',
name: 'any',
payload: transfer(new Uint8Array([1, 2, 3]), [
new Uint8Array([1, 2, 3]).buffer,
]),
});
ctx.handlers.unsubscribe({ type: 'unsubscribe', id: 'any:1' });
await vi.advanceTimersToNextTimerAsync();
expect(ctx.postMessage.mock.calls).toMatchInlineSnapshot(`
[
[
{
"id": "any:1",
"type": "complete",
},
],
]
`);
});
});

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AutoMessageHandler,
ignoreUnknownEvent,
KNOWN_MESSAGE_TYPES,
type MessageCommunicapable,
type MessageHandlers,
} from '../message';
class CustomMessageHandler extends AutoMessageHandler {
public handlers: Partial<MessageHandlers> = {
call: vi.fn(),
cancel: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
return: vi.fn(),
next: vi.fn(),
error: vi.fn(),
complete: vi.fn(),
};
}
declare module 'vitest' {
interface TestContext {
sendPort: MessageCommunicapable;
receivePort: MessageCommunicapable;
handler: CustomMessageHandler;
}
}
describe('message', () => {
beforeEach(ctx => {
const listeners: ((event: MessageEvent) => void)[] = [];
ctx.sendPort = {
postMessage: (msg: any) => {
listeners.forEach(listener => {
listener(new MessageEvent('message', { data: msg }));
});
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
ctx.receivePort = {
postMessage: vi.fn(),
addEventListener: vi.fn((_event, handler) => {
listeners.push(handler);
}),
removeEventListener: vi.fn(),
};
ctx.handler = new CustomMessageHandler(ctx.receivePort);
});
it('should ignore unknown message type', ctx => {
const handler = vi.fn();
// @ts-expect-error internal api
ctx.handler.handleMessage = ignoreUnknownEvent(handler);
ctx.sendPort.postMessage('connected');
ctx.sendPort.postMessage({ type: 'call1' });
ctx.sendPort.postMessage(new Uint8Array());
ctx.sendPort.postMessage(null);
ctx.sendPort.postMessage(undefined);
expect(handler).not.toHaveBeenCalled();
});
it('should handle known message type', async ctx => {
for (const type of KNOWN_MESSAGE_TYPES) {
ctx.sendPort.postMessage({ type });
expect(ctx.handler.handlers[type]).toBeCalled();
}
});
});

View File

@@ -0,0 +1,233 @@
import { merge } from 'lodash-es';
import { Observable, type Observer } from 'rxjs';
import {
AutoMessageHandler,
type CallMessage,
type CancelMessage,
fetchTransferables,
type MessageCommunicapable,
type MessageHandlers,
type SubscribeMessage,
type UnsubscribeMessage,
} from './message';
import type {
OpInput,
OpInputWithSignal,
OpNames,
OpOutput,
OpSchema,
} from './types';
export interface CancelablePromise<T> extends Promise<T> {
cancel(): void;
}
interface PendingCall extends PromiseWithResolvers<any> {
id: string;
timeout: number | NodeJS.Timeout;
}
export interface OpClientOptions {
timeout?: number;
}
function hydrateRemoteError(error: Error) {
const hydrated = Object.assign(new Error(), error);
if (
'stacktrace' in error &&
typeof error.stacktrace === 'string' &&
error.stacktrace.length > 0
) {
hydrated.stack = error.stacktrace;
}
return hydrated;
}
export class OpClient<Ops extends OpSchema> extends AutoMessageHandler {
private readonly callIds = new Map<OpNames<Ops>, number>();
private readonly pendingCalls = new Map<string, PendingCall>();
private readonly obs = new Map<string, Observer<any>>();
private readonly options: OpClientOptions = {
timeout: Infinity,
};
constructor(port: MessageCommunicapable, options: OpClientOptions = {}) {
super(port);
merge(this.options, options);
}
protected override get handlers() {
return {
return: this.handleReturnMessage,
next: this.handleSubscriptionNextMessage,
error: this.handleSubscriptionErrorMessage,
complete: this.handleSubscriptionCompleteMessage,
};
}
private readonly handleReturnMessage: MessageHandlers['return'] = msg => {
const pending = this.pendingCalls.get(msg.id);
if (!pending) {
return;
}
if ('error' in msg) {
pending.reject(hydrateRemoteError(msg.error));
} else {
pending.resolve(msg.data);
}
clearTimeout(pending.timeout);
this.pendingCalls.delete(msg.id);
};
private readonly handleSubscriptionNextMessage: MessageHandlers['next'] =
msg => {
const ob = this.obs.get(msg.id);
if (!ob) {
return;
}
ob.next(msg.data);
};
private readonly handleSubscriptionErrorMessage: MessageHandlers['error'] =
msg => {
const ob = this.obs.get(msg.id);
if (!ob) {
return;
}
ob.error(hydrateRemoteError(msg.error));
};
private readonly handleSubscriptionCompleteMessage: MessageHandlers['complete'] =
msg => {
const ob = this.obs.get(msg.id);
if (!ob) {
return;
}
ob.complete();
};
protected nextCallId(op: OpNames<Ops>) {
let id = this.callIds.get(op) ?? 0;
id++;
this.callIds.set(op, id);
return `${op}:${id}`;
}
protected currentCallId(op: OpNames<Ops>) {
return this.callIds.get(op) ?? 0;
}
call<Op extends OpNames<Ops>>(
op: Op,
...args: OpInputWithSignal<Ops, Op>
): CancelablePromise<OpOutput<Ops, Op>> {
const promiseWithResolvers = Promise.withResolvers<any>();
if (args.length > 1 && args[args.length - 1] === undefined) {
args.pop();
}
const abortSignal =
args[args.length - 1] instanceof AbortSignal
? (args.pop() as AbortSignal)
: undefined;
const payload = args.pop();
const msg = {
type: 'call',
id: this.nextCallId(op),
name: op as string,
payload,
} satisfies CallMessage;
const promise = promiseWithResolvers.promise as CancelablePromise<any>;
const raise = (reason: any) => {
const pending = this.pendingCalls.get(msg.id);
if (!pending) {
return;
}
this.port.postMessage({
type: 'cancel',
id: msg.id,
} satisfies CancelMessage);
promiseWithResolvers.reject(reason);
clearTimeout(pending.timeout);
this.pendingCalls.delete(msg.id);
};
abortSignal?.addEventListener('abort', () => {
raise(abortSignal.reason);
});
promise.cancel = () => {
raise('canceled');
};
const timeout =
this.options.timeout === Infinity
? 0
: setTimeout(() => {
raise('timeout');
}, this.options.timeout);
const transferables = fetchTransferables(payload);
this.port.postMessage(msg, { transfer: transferables });
this.pendingCalls.set(msg.id, {
...promiseWithResolvers,
timeout,
id: msg.id,
});
return promise;
}
ob$<Op extends OpNames<Ops>, Out extends OpOutput<Ops, Op>>(
op: Op,
...args: OpInput<Ops, Op>
): Observable<Out> {
const sub$ = new Observable<Out>(ob => {
const payload = args[0];
const msg = {
type: 'subscribe',
id: this.nextCallId(op),
name: op as string,
payload,
} satisfies SubscribeMessage;
const transferables = fetchTransferables(payload);
this.port.postMessage(msg, { transfer: transferables });
this.obs.set(msg.id, ob);
return () => {
ob.complete();
this.obs.delete(msg.id);
this.port.postMessage({
type: 'unsubscribe',
id: msg.id,
} satisfies UnsubscribeMessage);
};
});
return sub$;
}
destroy() {
super.close();
this.pendingCalls.forEach(call => {
call.reject(new Error('client destroyed'));
});
this.pendingCalls.clear();
this.obs.forEach(ob => {
ob.complete();
});
this.obs.clear();
}
}

View File

@@ -0,0 +1,281 @@
import EventEmitter2 from 'eventemitter2';
import { pick } from 'lodash-es';
import { defer, from, fromEvent, Observable, of, take, takeUntil } from 'rxjs';
import { MANUALLY_STOP } from '../utils';
import {
AutoMessageHandler,
type CallMessage,
fetchTransferables,
type MessageHandlers,
type ReturnMessage,
type SubscribeMessage,
type SubscriptionCompleteMessage,
type SubscriptionErrorMessage,
type SubscriptionNextMessage,
} from './message';
import type { OpInput, OpNames, OpOutput, OpSchema } from './types';
const SERIALIZABLE_ERROR_FIELDS = [
'name',
'message',
'code',
'type',
'status',
'data',
'stacktrace',
] as const;
type SerializableErrorShape = Partial<
Record<(typeof SERIALIZABLE_ERROR_FIELDS)[number], unknown>
> & {
name?: string;
message?: string;
};
function getFallbackErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error && error.message) {
return error.message;
}
if (
typeof error === 'number' ||
typeof error === 'boolean' ||
typeof error === 'bigint' ||
typeof error === 'symbol'
) {
return String(error);
}
if (error === null || error === undefined) {
return 'Unknown error';
}
try {
const jsonMessage = JSON.stringify(error);
if (jsonMessage && jsonMessage !== '{}') {
return jsonMessage;
}
} catch {
return 'Unknown error';
}
return 'Unknown error';
}
function serializeError(error: unknown): Error {
const valueToPick =
error && typeof error === 'object'
? error
: ({} as Record<string, unknown>);
const serialized = pick(
valueToPick,
SERIALIZABLE_ERROR_FIELDS
) as SerializableErrorShape;
if (!serialized.message || typeof serialized.message !== 'string') {
serialized.message = getFallbackErrorMessage(error);
}
if (!serialized.name || typeof serialized.name !== 'string') {
if (error instanceof Error && error.name) {
serialized.name = error.name;
} else if (error && typeof error === 'object') {
const constructorName = error.constructor?.name;
serialized.name =
typeof constructorName === 'string' && constructorName.length > 0
? constructorName
: 'Error';
} else {
serialized.name = 'Error';
}
}
if (
!serialized.stacktrace &&
error instanceof Error &&
typeof error.stack === 'string'
) {
serialized.stacktrace = error.stack;
}
return serialized as Error;
}
interface OpCallContext {
signal: AbortSignal;
}
export type OpHandler<Ops extends OpSchema, Op extends OpNames<Ops>> = (
payload: OpInput<Ops, Op>[0],
ctx: OpCallContext
) =>
| OpOutput<Ops, Op>
| Promise<OpOutput<Ops, Op>>
| Observable<OpOutput<Ops, Op>>;
export class OpConsumer<Ops extends OpSchema> extends AutoMessageHandler {
private readonly eventBus = new EventEmitter2();
private readonly registeredOpHandlers = new Map<
OpNames<Ops>,
OpHandler<Ops, any>
>();
private readonly processing = new Map<string, AbortController>();
override get handlers() {
return {
call: this.handleCallMessage,
cancel: this.handleCancelMessage,
subscribe: this.handleSubscribeMessage,
unsubscribe: this.handleCancelMessage,
};
}
private readonly handleCallMessage: MessageHandlers['call'] = msg => {
const abortController = new AbortController();
this.processing.set(msg.id, abortController);
this.eventBus.emit(`before:${msg.name}`, msg.payload);
this.ob$(msg, abortController.signal)
.pipe(take(1))
.subscribe({
next: data => {
this.eventBus.emit(`after:${msg.name}`, msg.payload, data);
const transferables = fetchTransferables(data);
this.port.postMessage(
{
type: 'return',
id: msg.id,
data,
} satisfies ReturnMessage,
{ transfer: transferables }
);
},
error: error => {
this.port.postMessage({
type: 'return',
id: msg.id,
error: serializeError(error),
} satisfies ReturnMessage);
},
complete: () => {
this.processing.delete(msg.id);
},
});
};
private readonly handleSubscribeMessage: MessageHandlers['subscribe'] =
msg => {
const abortController = new AbortController();
this.processing.set(msg.id, abortController);
this.ob$(msg, abortController.signal).subscribe({
next: data => {
const transferables = fetchTransferables(data);
this.port.postMessage(
{
type: 'next',
id: msg.id,
data,
} satisfies SubscriptionNextMessage,
{ transfer: transferables }
);
},
error: error => {
this.port.postMessage({
type: 'error',
id: msg.id,
error: serializeError(error),
} satisfies SubscriptionErrorMessage);
},
complete: () => {
this.port.postMessage({
type: 'complete',
id: msg.id,
} satisfies SubscriptionCompleteMessage);
this.processing.delete(msg.id);
},
});
};
private readonly handleCancelMessage: MessageHandlers['cancel'] &
MessageHandlers['unsubscribe'] = msg => {
const abortController = this.processing.get(msg.id);
if (!abortController) {
return;
}
abortController.abort(MANUALLY_STOP);
};
register<Op extends OpNames<Ops>>(op: Op, handler: OpHandler<Ops, Op>) {
this.registeredOpHandlers.set(op, handler);
}
registerAll(
handlers: OpNames<Ops> extends string
? { [K in OpNames<Ops>]: OpHandler<Ops, K> }
: never
) {
for (const [op, handler] of Object.entries(handlers)) {
this.register(op as any, handler as any);
}
}
before<Op extends OpNames<Ops>>(
op: Op,
handler: (...input: OpInput<Ops, Op>) => void
) {
this.eventBus.on(`before:${op}`, handler);
}
after<Op extends OpNames<Ops>>(
op: Op,
handler: (...args: [...OpInput<Ops, Op>, OpOutput<Ops, Op>]) => void
) {
this.eventBus.on(`after:${op}`, handler);
}
/**
* @internal
*/
ob$(op: CallMessage | SubscribeMessage, signal: AbortSignal) {
return defer(() => {
const handler = this.registeredOpHandlers.get(op.name as any);
if (!handler) {
throw new Error(
`Handler for operation [${op.name}] is not registered.`
);
}
const ret$ = handler(op.payload, { signal });
let ob$: Observable<any>;
if (ret$ instanceof Promise) {
ob$ = from(ret$);
} else if (ret$ instanceof Observable) {
ob$ = ret$;
} else {
ob$ = of(ret$);
}
return ob$.pipe(takeUntil(fromEvent(signal, 'abort')));
});
}
destroy() {
super.close();
this.registeredOpHandlers.clear();
this.processing.forEach(controller => {
controller.abort(MANUALLY_STOP);
});
this.processing.clear();
this.eventBus.removeAllListeners();
}
}

View File

@@ -0,0 +1,4 @@
export * from './client';
export * from './consumer';
export { type MessageCommunicapable, transfer } from './message';
export type { OpSchema } from './types';

View File

@@ -0,0 +1,168 @@
const PRODUCER_MESSAGE_TYPES = [
'call',
'cancel',
'subscribe',
'unsubscribe',
] as const;
const CONSUMER_MESSAGE_TYPES = ['return', 'next', 'error', 'complete'] as const;
export const KNOWN_MESSAGE_TYPES = new Set([
...PRODUCER_MESSAGE_TYPES,
...CONSUMER_MESSAGE_TYPES,
]);
type MessageType =
| (typeof PRODUCER_MESSAGE_TYPES)[number]
| (typeof CONSUMER_MESSAGE_TYPES)[number];
export interface Message {
type: MessageType;
}
// in
export interface CallMessage extends Message {
type: 'call';
id: string;
name: string;
payload: any;
}
export interface CancelMessage extends Message {
type: 'cancel';
id: string;
}
export interface SubscribeMessage extends Message {
type: 'subscribe';
id: string;
name: string;
payload: any;
}
export interface UnsubscribeMessage extends Message {
type: 'unsubscribe';
id: string;
}
// out
export type ReturnMessage = {
type: 'return';
id: string;
} & (
| {
data: any;
}
| {
error: Error;
}
);
export interface SubscriptionNextMessage extends Message {
type: 'next';
id: string;
data: any;
}
export interface SubscriptionErrorMessage extends Message {
type: 'error';
id: string;
error: Error;
}
export type SubscriptionCompleteMessage = {
type: 'complete';
id: string;
};
export type Messages =
| CallMessage
| CancelMessage
| SubscribeMessage
| UnsubscribeMessage
| ReturnMessage
| SubscriptionNextMessage
| SubscriptionErrorMessage
| SubscriptionCompleteMessage;
export type MessageHandlers = {
[Type in Messages['type']]: (
message: Extract<Messages, { type: Type }>
) => void;
};
export type MessageCommunicapable = Pick<
MessagePort,
'postMessage' | 'addEventListener' | 'removeEventListener'
> & {
start?(): void;
close?(): void;
terminate?(): void; // For Worker
};
export function ignoreUnknownEvent(handler: (data: Messages) => void) {
return (event: MessageEvent<Message>) => {
const data = event.data;
if (
!data ||
typeof data !== 'object' ||
typeof data.type !== 'string' ||
!KNOWN_MESSAGE_TYPES.has(data.type)
) {
return;
}
handler(data as any);
};
}
const TRANSFERABLES_CACHE = new Map<any, Transferable[]>();
export function transfer<T>(data: T, transferables: Transferable[]): T {
TRANSFERABLES_CACHE.set(data, transferables);
return data;
}
export function fetchTransferables(data: any): Transferable[] | undefined {
const transferables = TRANSFERABLES_CACHE.get(data);
if (transferables) {
TRANSFERABLES_CACHE.delete(data);
}
return transferables;
}
export abstract class AutoMessageHandler {
private listening = false;
protected abstract handlers: Partial<MessageHandlers>;
constructor(protected readonly port: MessageCommunicapable) {
this.listen();
}
protected handleMessage = ignoreUnknownEvent((msg: Messages) => {
const handler = this.handlers[msg.type];
if (!handler) {
return;
}
handler(msg as any);
});
protected listen() {
if (this.listening) {
return;
}
this.port.addEventListener('message', this.handleMessage);
this.port.addEventListener('messageerror', console.error);
this.port.start?.();
this.listening = true;
}
close() {
this.port.close?.();
this.port.terminate?.(); // For Worker
this.port.removeEventListener('message', this.handleMessage);
this.port.removeEventListener('messageerror', console.error);
this.listening = false;
}
}

View File

@@ -0,0 +1,50 @@
type KeyToKey<T extends OpSchema> = {
[K in keyof T]: string extends K ? never : K;
};
declare type ValuesOf<T> = T extends {
[K in keyof T]: infer _U;
}
? _U
: never;
export interface OpSchema {
[key: string]: [any, any?];
}
type IsAny<T> = 0 extends 1 & T ? true : false;
type RequiredInput<In> =
IsAny<In> extends true
? [In]
: [In] extends [never]
? []
: [In] extends [void]
? []
: [In];
export type OpNames<T extends OpSchema> = ValuesOf<KeyToKey<T>>;
export type OpInput<
Ops extends OpSchema,
Type extends OpNames<Ops>,
> = Type extends keyof Ops
? Ops[Type] extends [infer In]
? RequiredInput<In>
: Ops[Type] extends [infer In, infer _Out]
? RequiredInput<In>
: never
: never;
export type OpInputWithSignal<Ops extends OpSchema, Type extends OpNames<Ops>> =
OpInput<Ops, Type> extends [infer In]
? [In, AbortSignal | undefined] | [In]
: [AbortSignal | undefined] | [];
export type OpOutput<
Ops extends OpSchema,
Type extends OpNames<Ops>,
> = Type extends keyof Ops
? Ops[Type] extends [infer _In, infer Out]
? Out
: never
: never;

View File

@@ -0,0 +1,127 @@
import {
beforeEach,
describe,
expect,
test as vitest,
type TestAPI,
} from 'vitest';
import {
createORMClient,
type DBSchemaBuilder,
f,
MemoryORMAdapter,
t,
Table,
} from '../';
const TEST_SCHEMA = {
docProperties: t.document({
docId: f.string().primaryKey(),
}),
} satisfies DBSchemaBuilder;
const ORMClient = createORMClient(TEST_SCHEMA);
type Context = {
client: InstanceType<typeof ORMClient>;
};
beforeEach<Context>(async t => {
t.client = new ORMClient(new MemoryORMAdapter());
});
const test = vitest as TestAPI<Context>;
describe('ORM entity CRUD', () => {
test('still have type check', t => {
const { client } = t;
expect(() =>
// @ts-expect-error type test
client.docProperties.create({
// docId missed
prop1: 'prop1:value',
prop2: 'prop2:value',
})
).toThrow();
});
test('should be able to create ORM client', t => {
const { client } = t;
expect(client.docProperties instanceof Table).toBe(true);
});
test('should be able to create entity', async t => {
const { client } = t;
const doc = client.docProperties.create({
docId: '1',
prop1: 'prop1:value',
prop2: 'prop2:value',
});
expect(doc.docId).toBe('1');
expect(doc.prop1).toBe('prop1:value');
expect(doc.prop2).toBe('prop2:value');
});
test('should be able to read entity', async t => {
const { client } = t;
const doc = client.docProperties.create({
docId: '1',
prop1: 'prop1:value',
prop2: 'prop2:value',
});
const doc2 = client.docProperties.get(doc.docId);
expect(doc2).toStrictEqual(doc);
});
test('should be able to update entity', async t => {
const { client } = t;
const doc = client.docProperties.create({
docId: '1',
prop1: 'prop1:value',
prop2: 'prop2:value',
});
client.docProperties.update(doc.docId, {
prop1: 'prop1:value2',
prop3: 'prop3:value',
prop4: null,
prop5: undefined,
});
const doc2 = client.docProperties.get(doc.docId);
expect(doc2).toStrictEqual({
docId: '1',
prop1: 'prop1:value2',
prop2: 'prop2:value',
prop3: 'prop3:value',
prop4: null,
prop5: undefined,
});
});
test('should be able to delete entity', async t => {
const { client } = t;
const doc = client.docProperties.create({
docId: '1',
prop1: 'prop1:value',
prop2: 'prop2:value',
});
client.docProperties.delete(doc.docId);
const doc2 = client.docProperties.get(doc.docId);
expect(doc2).toBe(null);
});
});

View File

@@ -0,0 +1,139 @@
import { nanoid } from 'nanoid';
import { beforeEach, describe, expect, test as t, type TestAPI } from 'vitest';
import {
createORMClient,
type DBSchemaBuilder,
f,
MemoryORMAdapter,
Table,
} from '../';
const TEST_SCHEMA = {
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
},
} satisfies DBSchemaBuilder;
const ORMClient = createORMClient(TEST_SCHEMA);
type Context = {
client: InstanceType<typeof ORMClient>;
};
beforeEach<Context>(async t => {
t.client = new ORMClient(new MemoryORMAdapter());
});
const test = t as TestAPI<Context>;
describe('ORM entity CRUD', () => {
test('should be able to create ORM client', t => {
const { client } = t;
expect(client.tags instanceof Table).toBe(true);
});
test('should be able to create entity', async t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
expect(tag.id).toBeDefined();
expect(tag.name).toBe('test');
expect(tag.color).toBe('red');
});
test('should be able to read entity', async t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
const tag2 = client.tags.get(tag.id);
expect(tag2).toEqual(tag);
});
test('should be able to list keys', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
expect(client.tags.keys()).toStrictEqual([tag.id]);
client.tags.delete(tag.id);
expect(client.tags.keys()).toStrictEqual([]);
});
test('should be able to update entity', async t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
client.tags.update(tag.id, {
name: 'test2',
});
const tag2 = client.tags.get(tag.id);
expect(tag2).toEqual({
id: tag.id,
name: 'test2',
color: 'red',
});
// old tag should not be updated
expect(tag.name).not.toBe(tag2!.name);
});
test('should be able to delete entity', async t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
client.tags.delete(tag.id);
const tag2 = client.tags.get(tag.id);
expect(tag2).toBe(null);
});
test('should be able to recover entity', t => {
const { client } = t;
client.tags.create({
id: '1',
name: 'test',
color: 'red',
});
client.tags.delete('1');
client.tags.create({
id: '1',
name: 'test',
color: 'red',
});
const tag = client.tags.get('1');
expect(tag).toEqual({
id: '1',
name: 'test',
color: 'red',
});
});
});

View File

@@ -0,0 +1,130 @@
import { nanoid } from 'nanoid';
import { beforeEach, describe, expect, test as t, type TestAPI } from 'vitest';
import {
createORMClient,
type DBSchemaBuilder,
type Entity,
f,
MemoryORMAdapter,
} from '../';
const TEST_SCHEMA = {
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string().optional(),
colors: f.json<string[]>().optional(),
},
badges: {
id: f.string().primaryKey().default(nanoid),
color: f.string(),
},
} satisfies DBSchemaBuilder;
const ORMClient = createORMClient(TEST_SCHEMA);
// define the hooks
ORMClient.defineHook('tags', 'migrate field `color` to field `colors`', {
deserialize(data) {
if (!data.colors && data.color) {
data.colors = [data.color];
}
return data;
},
});
type Context = {
client: InstanceType<typeof ORMClient>;
};
beforeEach<Context>(async t => {
t.client = new ORMClient(new MemoryORMAdapter());
});
const test = t as TestAPI<Context>;
describe('ORM hook mixin', () => {
test('create entity', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
expect(tag.colors).toStrictEqual(['red']);
});
test('read entity', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
const tag2 = client.tags.get(tag.id);
expect(tag2!.colors).toStrictEqual(['red']);
});
test('update entity', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
const tag2 = client.tags.update(tag.id, { color: 'blue' });
expect(tag2!.colors).toStrictEqual(['blue']);
});
test('subscribe entity', t => {
const { client } = t;
let tag: Entity<(typeof TEST_SCHEMA)['tags']> | null = null;
const subscription = client.tags.get$('test').subscribe(data => {
tag = data;
});
client.tags.create({
id: 'test',
name: 'test',
color: 'red',
});
expect(tag!.colors).toStrictEqual(['red']);
client.tags.update(tag!.id, { color: 'blue' });
expect(tag!.colors).toStrictEqual(['blue']);
subscription.unsubscribe();
});
test('should not run hook on unrelated entity', t => {
const { client } = t;
const badge = client.badges.create({
color: 'red',
});
// @ts-expect-error test
expect(badge.colors).toBeUndefined();
});
test('should not touch the data in storage', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
expect(tag.colors).toStrictEqual(['red']);
// @ts-expect-error private
const rawTag = client.tags.adapter.data.get(tag.id);
expect(rawTag.color).toBe('red');
expect(rawTag.colors).toBe(undefined);
});
});

View File

@@ -0,0 +1,145 @@
import { nanoid } from 'nanoid';
import { describe, expect, test } from 'vitest';
import { createORMClient, f, MemoryORMAdapter } from '../';
describe('Schema validations', () => {
test('primary key must be set', () => {
expect(() =>
createORMClient({
tags: {
id: f.string(),
name: f.string(),
},
})
).toThrow(
'[Table(tags)]: There should be at least one field marked as primary key.'
);
});
test('primary key must be unique', () => {
expect(() =>
createORMClient({
tags: {
id: f.string().primaryKey(),
name: f.string().primaryKey(),
},
})
).toThrow(
'[Table(tags)]: There should be only one field marked as primary key.'
);
});
test('primary key should not be optional without default value', () => {
expect(() =>
createORMClient({
tags: {
id: f.string().primaryKey().optional(),
name: f.string(),
},
})
).toThrow(
"[Table(tags)]: Field 'id' can't be marked primary key and optional with no default value provider at the same time."
);
});
test('primary key can be optional with default value', async () => {
expect(() =>
createORMClient({
tags: {
id: f.string().primaryKey().optional().default(nanoid),
name: f.string(),
},
})
).not.throws();
});
});
describe('Entity validations', () => {
const Client = createORMClient({
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
status: f.enum('active', 'inactive').optional(),
},
});
function createTagsClient() {
return new Client(new MemoryORMAdapter());
}
test('should not update primary key', () => {
const client = createTagsClient();
const tag = client.tags.create({
name: 'tag',
color: 'blue',
});
// @ts-expect-error test
expect(() => client.tags.update(tag.id, { id: 'new-id' })).toThrow(
"[Table(tags)]: Primary key field 'id' can't be updated."
);
});
test('should throw when trying to create entity with missing required field', () => {
const client = createTagsClient();
// @ts-expect-error test
expect(() => client.tags.create({ name: 'test' })).toThrow(
"[Table(tags)]: Field 'color' is required but not set."
);
});
test('should throw when trying to create entity with extra field', () => {
const client = createTagsClient();
expect(() =>
// @ts-expect-error test
client.tags.create({ name: 'test', color: 'red', extra: 'field' })
).toThrow("[Table(tags)]: Field 'extra' is not defined but set in entity.");
});
test('should throw when trying to create entity with unexpected field type', () => {
const client = createTagsClient();
// @ts-expect-error test
expect(() => client.tags.create({ name: 'test', color: 123 })).toThrow(
"[Table(tags)]: Field 'color' type mismatch. Expected type 'string' but got 'number'."
);
// @ts-expect-error test
expect(() => client.tags.create({ name: 'test', color: [123] })).toThrow(
"[Table(tags)]: Field 'color' type mismatch. Expected type 'string' but got 'json'"
);
});
test('should be able to assign `null` to json field', () => {
expect(() => {
const Client = createORMClient({
tags: {
id: f.string().primaryKey().default(nanoid),
info: f.json(),
},
});
const client = new Client(new MemoryORMAdapter());
const tag = client.tags.create({ info: null });
expect(tag.info).toBe(null);
});
});
test('should throw when trying to create entity with invalid enum value', () => {
const client = createTagsClient();
expect(() =>
// @ts-expect-error test
client.tags.create({ name: 'test', status: 'not-active' })
).toThrow(
"[Table(tags)]: Field 'status' value 'not-active' is not valid. Expected one of [active, inactive]."
);
});
});

View File

@@ -0,0 +1,635 @@
import { nanoid } from 'nanoid';
import {
beforeEach,
describe,
expect,
test as vitest,
type TestAPI,
} from 'vitest';
import { Doc } from 'yjs';
import {
createORMClient,
type DBSchemaBuilder,
type DocProvider,
type Entity,
f,
t,
Table,
YjsDBAdapter,
} from '../';
function incremental() {
let i = 0;
return () => i++;
}
const TEST_SCHEMA = {
tags: {
id: f.string().primaryKey().default(nanoid),
name: f.string(),
color: f.string(),
},
users: {
id: f.number().primaryKey().default(incremental()),
name: f.string(),
email: f.string().optional(),
},
userInfo: t.document({
userId: f.number().primaryKey(),
}),
} satisfies DBSchemaBuilder;
const docProvider: DocProvider = {
getDoc(guid: string) {
return new Doc({ guid });
},
};
const Client = createORMClient(TEST_SCHEMA);
type Context = {
client: InstanceType<typeof Client>;
};
beforeEach<Context>(async t => {
t.client = new Client(new YjsDBAdapter(TEST_SCHEMA, docProvider));
});
const test = vitest as TestAPI<Context>;
describe('ORM entity CRUD', () => {
test('should be able to create ORM client', t => {
const { client } = t;
expect(client.tags instanceof Table).toBe(true);
});
test('should be able to create entity', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
expect(tag.id).toBeDefined();
expect(tag.name).toBe('test');
expect(tag.color).toBe('red');
const user = client.users.create({
name: 'user1',
});
expect(typeof user.id).toBe('number');
expect(user.name).toBe('user1');
});
test('should be able to read entity', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
const tag2 = client.tags.get(tag.id);
expect(tag2).toEqual(tag);
const user = client.users.create({
name: 'user1',
});
const user2 = client.users.get(user.id);
expect(user2).toEqual(user);
});
test('should be able to select', t => {
const { client } = t;
client.users.create({
name: 'u1',
email: 'e1@example.com',
});
client.users.create({
name: 'u2',
});
const users = client.users.select('name');
expect(users).toStrictEqual([
{ id: expect.any(Number), name: 'u1' },
{ id: expect.any(Number), name: 'u2' },
]);
const user2 = client.users.select('email');
expect(user2).toStrictEqual([
{ id: expect.any(Number), email: 'e1@example.com' },
{ id: expect.any(Number), email: undefined },
]);
const user3 = client.users.select('name', {
email: null,
});
expect(user3).toStrictEqual([{ id: expect.any(Number), name: 'u2' }]);
});
test('should be able to observe select', t => {
const { client } = t;
const t1 = client.tags.create({
name: 't1',
color: 'red',
});
const t2 = client.tags.create({
name: 't2',
color: 'blue',
});
let currentValue: any;
let callbackCount = 0;
client.tags.select$('name', { color: 'red' }).subscribe(data => {
currentValue = data;
callbackCount++;
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1' },
]);
expect(callbackCount).toBe(1);
const t3 = client.tags.create({
name: 't3',
color: 'blue',
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1' },
]);
expect(callbackCount).toBe(1);
client.tags.update(t1.id, {
name: 't1-updated',
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1-updated' },
]);
expect(callbackCount).toBe(2);
client.tags.update(t2.id, {
color: 'red',
});
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't1-updated' },
{ id: expect.any(String), name: 't2' },
]);
expect(callbackCount).toBe(3);
client.tags.delete(t1.id);
expect(currentValue).toStrictEqual([
{ id: expect.any(String), name: 't2' },
]);
expect(callbackCount).toBe(4);
client.tags.delete(t3.id);
expect(callbackCount).toBe(4);
});
test('should be able to filter with nullable condition', t => {
const { client } = t;
client.users.create({
name: 'u1',
email: 'e1@example.com',
});
client.users.create({
name: 'u2',
});
const users = client.users.find({
email: null,
});
expect(users).toHaveLength(1);
expect(users[0].email).toBeFalsy();
const users2 = client.users.find({
email: {
not: null,
},
});
expect(users2).toHaveLength(1);
expect(users2[0].email).toEqual('e1@example.com');
});
test('should be able to filter with `not` condition', t => {
const { client } = t;
client.users.create({
name: 'u1',
email: 'e1@example.com',
});
const users = client.users.find({
email: {
not: 'e1@example.com',
},
});
expect(users).toHaveLength(0);
const users2 = client.users.find({
name: {
not: 'u2',
},
});
expect(users2).toHaveLength(1);
expect(users2[0].name).toEqual('u1');
});
test('should be able to update entity', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
client.tags.update(tag.id, {
name: 'test2',
});
const tag2 = client.tags.get(tag.id);
expect(tag2).toEqual({
id: tag.id,
name: 'test2',
color: 'red',
});
// old tag should not be updated
expect(tag.name).not.toBe(tag2!.name);
});
test('should be able to delete entity', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
client.tags.delete(tag.id);
const tag2 = client.tags.get(tag.id);
expect(tag2).toBe(null);
});
test('should be able to recover entity', t => {
const { client } = t;
client.tags.create({
id: '1',
name: 'test',
color: 'red',
});
client.tags.delete('1');
client.tags.create({
id: '1',
name: 'test',
color: 'red',
});
const tag = client.tags.get('1');
expect(tag).toEqual({
id: '1',
name: 'test',
color: 'red',
});
});
test('should be able to list keys', t => {
const { client } = t;
const tag = client.tags.create({
name: 'test',
color: 'red',
});
expect(client.tags.keys()).toStrictEqual([tag.id]);
client.tags.delete(tag.id);
expect(client.tags.keys()).toStrictEqual([]);
});
test('should be able to subscribe to entity changes', t => {
const { client } = t;
let tag: Entity<(typeof TEST_SCHEMA)['tags']> | null = null;
const subscription1 = client.tags.get$('test').subscribe(data => {
tag = data;
});
const subscription2 = client.tags.get$('test').subscribe(_ => {});
expect(tag).toBe(null);
// create
client.tags.create({
id: 'test',
name: 'testTag',
color: 'blue',
});
expect(tag!.id).toEqual('test');
expect(tag!.color).toEqual('blue');
client.tags.update('test', {
color: 'red',
});
expect(tag!.color).toEqual('red');
client.tags.delete('test');
expect(tag).toBe(null);
// internal status
subscription1.unsubscribe();
// @ts-expect-error private field
expect(client.tags.subscribedKeys.size).toBe(1);
subscription2.unsubscribe();
// @ts-expect-error private field
expect(client.tags.subscribedKeys.size).toBe(0);
});
test('should be able to subscribe to entity key list', t => {
const { client } = t;
let callbackCount = 0;
let keys: string[] = [];
const subscription = client.tags.keys$().subscribe(data => {
keys = data;
callbackCount++;
});
client.tags.create({
id: 'test',
name: 'testTag',
color: 'blue',
});
expect(keys).toStrictEqual(['test']);
client.tags.update('test', { color: 'red' });
expect(keys).toStrictEqual(['test']);
client.tags.delete('test');
expect(keys).toStrictEqual([]);
expect(callbackCount).toStrictEqual(3); // init, create, delete
subscription.unsubscribe();
});
test('should be able to subscribe to filtered entity changes', t => {
const { client } = t;
let callbackCount = 0;
let entities: any[] = [];
const subscription = client.tags.find$({ name: 'test' }).subscribe(data => {
entities = data;
callbackCount++;
});
const tag1 = client.tags.create({
id: '1',
name: 'test',
color: 'red',
});
expect(entities).toStrictEqual([tag1]);
const tag2 = client.tags.create({
id: '2',
name: 'test',
color: 'blue',
});
expect(entities).toStrictEqual([tag1, tag2]);
client.tags.create({
id: '3',
name: 'not-test',
color: 'yellow',
});
expect(entities).toStrictEqual([tag1, tag2]);
expect(callbackCount).toStrictEqual(3);
client.tags.update('1', { color: 'green' });
expect(entities).toStrictEqual([{ ...tag1, color: 'green' }, tag2]);
client.tags.delete('1');
expect(entities).toStrictEqual([tag2]);
client.tags.delete('2');
expect(entities).toStrictEqual([]);
subscription.unsubscribe();
});
test('should be able to subscription to any entity changes', t => {
const { client } = t;
let entities: any[] = [];
const subscription = client.tags.find$().subscribe(data => {
entities = data;
});
const tag1 = client.tags.create({
id: '1',
name: 'tag1',
color: 'red',
});
expect(entities).toStrictEqual([tag1]);
const tag2 = client.tags.create({
id: '2',
name: 'tag2',
color: 'blue',
});
expect(entities).toStrictEqual([tag1, tag2]);
subscription.unsubscribe();
});
test('can not use reserved keyword as field name', () => {
expect(
() =>
new YjsDBAdapter(
{
tags: {
$$DELETED: f.string().primaryKey().default(nanoid),
},
},
docProvider
)
).toThrow(
"[Table(tags)]: Field '$$DELETED' is reserved keyword and can't be used"
);
});
test('should be able to validate entity data', t => {
const { client } = t;
expect(() => {
client.users.create({
// @ts-expect-error ignore the type error
name: null,
});
}).toThrowError("Field 'name' is required but not set.");
expect(() => {
// @ts-expect-error ignore the type error
client.users.create({});
}).toThrowError("Field 'name' is required but not set.");
expect(() => {
client.users.update(1, {
// @ts-expect-error ignore the type error
name: null,
});
}).toThrowError("Field 'name' is required but not set.");
});
test('should be able to set optional field to null', t => {
const { client } = t;
{
const user = client.users.create({
name: 'test',
});
expect(user.email).toBe(undefined);
}
{
const user = client.users.create({
name: 'test',
email: null,
});
expect(user.email).toBe(null);
}
{
const user = client.users.create({
name: 'test',
email: 'test@example.com',
});
client.users.update(user.id, {
email: null,
});
expect(client.users.get(user.id)!.email).toBe(null);
}
});
test('should be able to find entity by optional field', t => {
const { client } = t;
const user = client.users.create({
name: 'test',
email: null,
});
{
const found = client.users.find({ email: null });
expect(found).toEqual([user]);
}
{
const found = client.users.find({ email: undefined });
expect(found).toEqual([]);
}
});
test('should be able to create document entity', t => {
const { client } = t;
const doc = client.userInfo.create({
userId: 1,
avatar: 'avatar.jpg',
address: '123 Main St',
});
expect(doc.userId).toBe(1);
expect(doc.avatar).toBe('avatar.jpg');
expect(doc.address).toBe('123 Main St');
});
test('should be able to read document entity', t => {
const { client } = t;
const doc = client.userInfo.create({
userId: 1,
avatar: 'avatar.jpg',
address: '123 Main St',
});
const doc2 = client.userInfo.get(1);
expect(doc2).toStrictEqual(doc);
});
test('should be able to update document entity', t => {
const { client } = t;
const doc = client.userInfo.create({
userId: 1,
avatar: 'avatar.jpg',
address: '123 Main St',
});
client.userInfo.update(doc.userId, {
avatar: 'avatar2.jpg',
city: 'New York',
});
const doc2 = client.userInfo.get(1);
expect(doc2).toStrictEqual({
userId: 1,
avatar: 'avatar2.jpg',
address: '123 Main St',
city: 'New York',
});
});
test('should be able to delete document entity', t => {
const { client } = t;
const doc = client.userInfo.create({
userId: 1,
avatar: 'avatar.jpg',
address: '123 Main St',
});
client.userInfo.delete(doc.userId);
const doc2 = client.userInfo.get(1);
expect(doc2).toBe(null);
});
});

View File

@@ -0,0 +1,4 @@
export * from './memory/db';
export * from './mixins';
export * from './types';
export * from './yjs/db';

View File

@@ -0,0 +1,8 @@
import type { DBAdapter } from '../types';
import { MemoryTableAdapter } from './table';
export class MemoryORMAdapter implements DBAdapter {
table(tableName: string) {
return new MemoryTableAdapter(tableName);
}
}

View File

@@ -0,0 +1,180 @@
import { merge, pick } from 'lodash-es';
import { HookAdapter } from '../mixins';
import type {
DeleteQuery,
FindQuery,
InsertQuery,
ObserveQuery,
Select,
TableAdapter,
TableAdapterOptions,
UpdateQuery,
WhereCondition,
} from '../types';
@HookAdapter()
export class MemoryTableAdapter implements TableAdapter {
private readonly data = new Map<string, any>();
private keyField = 'key';
private readonly subscriptions = new Set<(key: string, data: any) => void>();
constructor(private readonly tableName: string) {}
setup(opts: TableAdapterOptions) {
this.keyField = opts.keyField;
}
dispose() {}
insert(query: InsertQuery) {
const { data, select } = query;
const key = String(data[this.keyField]);
if (this.data.has(key)) {
throw new Error(
`Record with key ${key} already exists in table ${this.tableName}`
);
}
this.data.set(key, data);
this.dispatch(key, data);
return this.value(data, select);
}
find(query: FindQuery) {
const { where, select } = query;
const result = [];
for (const record of this.iterate(where)) {
result.push(this.value(record, select));
}
return result;
}
observe(query: ObserveQuery): () => void {
const { where, select, callback } = query;
let listeningOnAll = false;
const obKeys = new Set<string>();
const results = [];
if (!where) {
listeningOnAll = true;
} else if ('byKey' in where) {
obKeys.add(where.byKey.toString());
}
for (const record of this.iterate(where)) {
const key = String(record[this.keyField]);
if (!listeningOnAll) {
obKeys.add(key);
}
results.push(this.value(record, select));
}
callback(results);
const ob = (key: string, data: any) => {
if (
listeningOnAll ||
obKeys.has(key) ||
(where && this.match(data, where))
) {
callback(this.find({ where, select }));
return;
}
};
this.subscriptions.add(ob);
return () => {
this.subscriptions.delete(ob);
};
}
update(query: UpdateQuery) {
const { where, data, select } = query;
const result = [];
for (let record of this.iterate(where)) {
record = merge({}, record, data);
const key = String(record[this.keyField]);
this.data.set(key, record);
this.dispatch(key, record);
result.push(this.value(this.value(record, select)));
}
return result;
}
delete(query: DeleteQuery) {
const { where } = query;
for (const record of this.iterate(where)) {
const key = String(record[this.keyField]);
this.data.delete(key);
this.dispatch(key, null);
}
}
toObject(record: any): Record<string, any> {
return record;
}
value(data: any, select: Select = '*') {
if (select === 'key') {
return data[this.keyField];
}
if (select === '*') {
return this.toObject(data);
}
return pick(this.toObject(data), select);
}
private *iterate(where: WhereCondition = []) {
if (Array.isArray(where)) {
for (const value of this.data.values()) {
if (this.match(value, where)) {
yield value;
}
}
} else {
const key = where.byKey;
const record = this.data.get(key.toString());
if (record) {
yield record;
}
}
}
private match(record: any, where: WhereCondition) {
if (Array.isArray(where)) {
return where.every(c => {
const value = record[c.field] || null;
const condition = c.value;
if (typeof condition === 'object') {
if (condition === null) {
return value === null;
}
if ('not' in condition) {
return value !== condition.not;
}
}
return value === condition;
});
}
return where.byKey === record[this.keyField];
}
private dispatch(key: string, data: any) {
this.subscriptions.forEach(callback => callback(key, data));
}
}

View File

@@ -0,0 +1,44 @@
import type { TableAdapter, TableAdapterOptions } from '../types';
declare module '../../types' {
interface TableOptions {
hooks?: Hook<unknown>[];
}
}
export interface Hook<T> {
deserialize(dbVal: T): T;
}
export interface TableAdapterWithHook<T = unknown> extends Hook<T> {}
export function HookAdapter(): ClassDecorator {
// @ts-expect-error allow
return (Class: { new (...args: any[]): TableAdapter }) => {
return class TableAdapterExtensions
extends Class
implements TableAdapterWithHook
{
hooks: Hook<unknown>[] = [];
override setup(opts: TableAdapterOptions): void {
super.setup(opts);
this.hooks = opts.hooks ?? [];
}
deserialize(data: unknown) {
if (!this.hooks.length) {
return data;
}
return this.hooks.reduce(
(acc, hook) => hook.deserialize(acc),
Object.assign({} as any, data)
);
}
override toObject(data: any): Record<string, any> {
return this.deserialize(super.toObject(data));
}
};
};
}

View File

@@ -0,0 +1 @@
export * from './hook';

View File

@@ -0,0 +1,76 @@
import type { Key, TableOptions } from '../types';
export interface TableAdapterOptions extends TableOptions {
keyField: string;
}
type OrmPrimitiveValues = string | number | boolean | null;
type SimpleCondition =
| OrmPrimitiveValues
| {
not: OrmPrimitiveValues;
};
type WhereSimpleCondition = {
field: string;
value: SimpleCondition;
};
type WhereByKeyCondition = {
byKey: Key;
};
// currently only support eq condition
// TODO(@forehalo): on the way [gt, gte, lt, lte, in, notIn, like, notLike, Or]
export type WhereCondition = Array<WhereSimpleCondition> | WhereByKeyCondition;
export type Select = '*' | 'key' | string[];
export type InsertQuery = {
data: any;
select?: Select;
};
export type DeleteQuery = {
where?: WhereCondition;
};
export type UpdateQuery = {
where?: WhereCondition;
data: any;
select?: Select;
};
export type FindQuery = {
where?: WhereCondition;
select?: Select;
};
export type ObserveQuery = {
where?: WhereCondition;
select?: Select;
callback: (data: any[]) => void;
};
export type Query =
| InsertQuery
| DeleteQuery
| UpdateQuery
| FindQuery
| ObserveQuery;
export interface TableAdapter {
setup(opts: TableAdapterOptions): void;
dispose(): void;
toObject(record: any): Record<string, any>;
insert(query: InsertQuery): any;
update(query: UpdateQuery): any[];
delete(query: DeleteQuery): void;
find(query: FindQuery): any[];
observe(query: ObserveQuery): () => void;
}
export interface DBAdapter {
table(tableName: string): TableAdapter;
}

View File

@@ -0,0 +1,35 @@
import type { Doc } from 'yjs';
import type { DBSchemaBuilder } from '../../schema';
import { validators } from '../../validators';
import type { DBAdapter, TableAdapter } from '../types';
import { YjsTableAdapter } from './table';
export interface DocProvider {
getDoc(guid: string): Doc;
}
export class YjsDBAdapter implements DBAdapter {
tables: Map<string, TableAdapter> = new Map();
constructor(
db: DBSchemaBuilder,
private readonly provider: DocProvider
) {
for (const [tableName, table] of Object.entries(db)) {
validators.validateYjsTableSchema(tableName, table);
const doc = this.provider.getDoc(tableName);
this.tables.set(tableName, new YjsTableAdapter(tableName, doc));
}
}
table(tableName: string) {
const table = this.tables.get(tableName);
if (!table) {
throw new Error('Table not found');
}
return table;
}
}

View File

@@ -0,0 +1,296 @@
import { pick } from 'lodash-es';
import {
type AbstractType,
type Doc,
Map as YMap,
type Transaction,
} from 'yjs';
import { shallowEqual } from '../../../../utils/shallow-equal';
import { validators } from '../../validators';
import { HookAdapter } from '../mixins';
import type {
DeleteQuery,
FindQuery,
InsertQuery,
ObserveQuery,
Select,
TableAdapter,
TableAdapterOptions,
UpdateQuery,
WhereCondition,
} from '../types';
/**
* Yjs Adapter for AFFiNE ORM
*
* Structure:
*
* Each table is a YDoc instance
*
* Table(YDoc)
* Key(string): Row(YMap)({
* FieldA(string): Value(Primitive)
* FieldB(string): Value(Primitive)
* FieldC(string): Value(Primitive)
* })
*/
@HookAdapter()
export class YjsTableAdapter implements TableAdapter {
private readonly deleteFlagKey = '$$DELETED';
private keyField: string = 'key';
private fields: string[] = [];
private readonly origin = 'YjsTableAdapter';
constructor(
private readonly tableName: string,
private readonly doc: Doc
) {}
setup(opts: TableAdapterOptions): void {
this.keyField = opts.keyField;
this.fields = Object.keys(opts.schema);
}
dispose() {
this.doc.destroy();
}
insert(query: InsertQuery) {
const { data, select } = query;
validators.validateYjsEntityData(this.tableName, data);
const key = data[this.keyField];
const record = this.doc.getMap(key.toString());
this.doc.transact(() => {
for (const key in data) {
if (data[key] === undefined) {
// skip undefined fields, avoid unexpected override
continue;
}
record.set(key, data[key]);
}
record.delete(this.deleteFlagKey);
}, this.origin);
return this.value(record, select);
}
update(query: UpdateQuery) {
const { data, select, where } = query;
validators.validateYjsEntityData(this.tableName, data);
const results: any[] = [];
this.doc.transact(() => {
for (const record of this.iterate(where)) {
results.push(this.value(record, select));
for (const key in data) {
this.setField(record, key, data[key]);
}
}
}, this.origin);
return results;
}
find(query: FindQuery) {
const { where, select } = query;
const records: any[] = [];
for (const record of this.iterate(where)) {
records.push(this.value(record, select));
}
return records;
}
observe(query: ObserveQuery) {
const { where, select, callback } = query;
let listeningOnAll = false;
const results = new Map<string, any>();
if (!where) {
listeningOnAll = true;
}
for (const record of this.iterate(where)) {
results.set(this.keyof(record), this.value(record, select));
}
callback(Array.from(results.values()));
const ob = (tx: Transaction) => {
let hasChanged = false;
for (const [ty] of tx.changed) {
const record = ty;
const key = this.keyof(record);
const isMatch =
(listeningOnAll || (where && this.match(record, where))) &&
!this.isDeleted(record);
const prevMatch = results.get(key);
const isPrevMatched = results.has(key);
if (isMatch && isPrevMatched) {
const newValue = this.value(record, select);
if (
!(
prevMatch === newValue ||
(!select && // if select is set, we will check the value
select !== '*' &&
select !== 'key' &&
// skip if the value is the same
shallowEqual(prevMatch, newValue))
)
) {
results.set(key, newValue);
hasChanged = true;
}
} else if (isMatch && !isPrevMatched) {
results.set(this.keyof(record), this.value(record, select));
hasChanged = true;
} else if (!isMatch && isPrevMatched) {
results.delete(key);
hasChanged = true;
}
}
if (hasChanged) {
callback(Array.from(results.values()));
}
};
this.doc.on('afterTransaction', ob);
return () => {
this.doc.off('afterTransaction', ob);
};
}
delete(query: DeleteQuery) {
const { where } = query;
this.doc.transact(() => {
for (const record of this.iterate(where)) {
this.deleteTy(record);
}
}, this.origin);
}
toObject(ty: AbstractType<any>): Record<string, any> {
return YMap.prototype.toJSON.call(ty);
}
private recordByKey(key: string): AbstractType<any> | null {
// detect if the record is there otherwise yjs will create an empty Map.
if (this.doc.share.has(key)) {
return this.doc.getMap(key);
}
return null;
}
private *iterate(where?: WhereCondition) {
if (!where) {
for (const map of this.doc.share.values()) {
if (!this.isDeleted(map)) {
yield map;
}
}
}
// fast pass for key lookup without iterating the whole table
else if ('byKey' in where) {
const record = this.recordByKey(where.byKey.toString());
if (record) {
yield record;
}
} else if (Array.isArray(where)) {
for (const map of this.doc.share.values()) {
if (this.match(map, where)) {
yield map;
}
}
}
}
private value(record: AbstractType<any>, select: Select = '*') {
if (this.isDeleted(record) || this.isEmpty(record)) {
return null;
}
let selectedFields: string[];
if (select === 'key') {
return this.keyof(record);
} else if (select === '*') {
return this.toObject(record);
} else {
selectedFields = select;
}
return pick(this.toObject(record), selectedFields);
}
private match(record: AbstractType<any>, where: WhereCondition) {
return (
!this.isDeleted(record) &&
(Array.isArray(where)
? where.length === 0
? false
: where.every(c => {
const field = this.field(record, c.field);
const condition = c.value;
if (typeof condition === 'object') {
if (condition === null) {
return field === null;
}
if ('not' in condition) {
return field !== condition.not;
}
}
return field === condition;
})
: where.byKey === this.keyof(record))
);
}
private isDeleted(record: AbstractType<any>) {
return (
this.field(record, this.deleteFlagKey) === true || this.isEmpty(record)
);
}
private keyof(record: AbstractType<any>) {
return this.field(record, this.keyField);
}
private field(ty: AbstractType<any>, field: string) {
const val = YMap.prototype.get.call(ty, field);
// only handle null will make the day easier
if (val === undefined) {
return null;
}
return val;
}
private setField(ty: AbstractType<any>, field: string, value: any) {
YMap.prototype.set.call(ty, field, value);
}
private isEmpty(ty: AbstractType<any>) {
return ty._map.size === 0;
}
private deleteTy(ty: AbstractType<any>) {
this.fields.forEach(field => {
if (field !== this.keyField) {
YMap.prototype.delete.call(ty, field);
}
});
YMap.prototype.set.call(ty, this.deleteFlagKey, true);
}
}

View File

@@ -0,0 +1,65 @@
import { type DBAdapter, type Hook } from './adapters';
import type { DBSchemaBuilder } from './schema';
import { type CreateEntityInput, Table, type TableMap } from './table';
import { validators } from './validators';
export class ORMClient {
static hooksMap: Map<string, Hook<any>[]> = new Map();
readonly tables = new Map<string, Table<any>>();
constructor(
protected readonly db: DBSchemaBuilder,
protected readonly adapter: DBAdapter
) {
Object.entries(db).forEach(([tableName, tableSchema]) => {
Object.defineProperty(this, tableName, {
get: () => {
let table = this.tables.get(tableName);
if (!table) {
table = new Table(this.adapter, tableName, {
schema: tableSchema,
hooks: ORMClient.hooksMap.get(tableName),
});
this.tables.set(tableName, table);
}
return table;
},
});
});
}
static defineHook(tableName: string, _desc: string, hook: Hook<any>) {
let hooks = this.hooksMap.get(tableName);
if (!hooks) {
hooks = [];
this.hooksMap.set(tableName, hooks);
}
hooks.push(hook);
}
}
export function createORMClient<Schema extends DBSchemaBuilder>(
db: Schema
): ORMClientWithTablesClass<Schema> {
Object.entries(db).forEach(([tableName, schema]) => {
validators.validateTableSchema(tableName, schema);
});
class ORMClientWithTables extends ORMClient {
constructor(adapter: DBAdapter) {
super(db, adapter);
}
}
return ORMClientWithTables as any;
}
export type ORMClientWithTablesClass<Schema extends DBSchemaBuilder> = {
new (adapter: DBAdapter): TableMap<Schema> & ORMClient;
defineHook<TableName extends keyof Schema>(
tableName: TableName,
desc: string,
hook: Hook<CreateEntityInput<Schema[TableName]>>
): void;
};

View File

@@ -0,0 +1,4 @@
export * from './adapters';
export * from './client';
export * from './schema';
export * from './table';

View File

@@ -0,0 +1,73 @@
export type FieldType = 'string' | 'number' | 'boolean' | 'json' | 'enum';
export interface FieldSchema<Type = unknown> {
type: FieldType;
optional: boolean;
isPrimaryKey: boolean;
default?: () => Type;
values?: Type[];
}
export type TableSchema = Record<string, FieldSchema>;
export type TableSchemaBuilder = Record<
string,
FieldSchemaBuilder<any, boolean>
>;
export type DocumentTableSchemaBuilder = TableSchemaBuilder & {
__document: FieldSchemaBuilder<boolean, true, false>;
};
export type DBSchemaBuilder = Record<string, TableSchemaBuilder>;
export class FieldSchemaBuilder<
Type = unknown,
Optional extends boolean = false,
PrimaryKey extends boolean = false,
> {
schema: FieldSchema = {
type: 'string',
optional: false,
isPrimaryKey: false,
default: undefined,
values: undefined,
};
constructor(type: FieldType, values?: string[]) {
this.schema.type = type;
this.schema.values = values;
}
optional() {
this.schema.optional = true;
return this as FieldSchemaBuilder<Type, true, PrimaryKey>;
}
default(value: () => Type) {
this.schema.default = value;
this.schema.optional = true;
return this as FieldSchemaBuilder<Type, true, PrimaryKey>;
}
primaryKey() {
this.schema.isPrimaryKey = true;
return this as FieldSchemaBuilder<Type, Optional, true>;
}
}
export const f = {
string: () => new FieldSchemaBuilder<string>('string'),
number: () => new FieldSchemaBuilder<number>('number'),
boolean: () => new FieldSchemaBuilder<boolean>('boolean'),
json: <T = any>() => new FieldSchemaBuilder<T>('json'),
enum: <T extends string>(...values: T[]) =>
new FieldSchemaBuilder<T>('enum', values),
} as const;
export const t = {
document: <T extends TableSchemaBuilder>(schema: T) => {
return {
...schema,
__document: new FieldSchemaBuilder<boolean>('boolean').optional(),
};
},
};

View File

@@ -0,0 +1,386 @@
import { isUndefined, omitBy } from 'lodash-es';
import { Observable, shareReplay } from 'rxjs';
import type { DBAdapter, TableAdapter } from './adapters';
import type {
DBSchemaBuilder,
DocumentTableSchemaBuilder,
FieldSchemaBuilder,
TableSchema,
TableSchemaBuilder,
} from './schema';
import type { Key, TableOptions } from './types';
import { validators } from './validators';
type Pretty<T> = T extends any
? {
-readonly [P in keyof T]: T[P];
}
: never;
// filter out all fields starting with `__`
type TableDefinedFieldNames<T extends TableSchemaBuilder> = keyof {
[K in keyof T as K extends `__${string}` ? never : K]: T[K];
};
type Typeof<F extends FieldSchemaBuilder> =
F extends FieldSchemaBuilder<infer Type> ? Type : never;
type RequiredFields<T extends TableSchemaBuilder> = {
[
K in TableDefinedFieldNames<T> as T[K] extends FieldSchemaBuilder<
any,
infer Optional
>
? Optional extends false
? K
: never
: never
]: Typeof<T[K]>;
};
type OptionalFields<T extends TableSchemaBuilder> = {
[
K in TableDefinedFieldNames<T> as T[K] extends FieldSchemaBuilder<
any,
infer Optional
>
? Optional extends true
? K
: never
: never
]?: Typeof<T[K]> | null;
};
type PrimaryKeyField<T extends TableSchemaBuilder> = {
[K in TableDefinedFieldNames<T>]: T[K] extends FieldSchemaBuilder<
any,
any,
infer PrimaryKey
>
? PrimaryKey extends true
? K
: never
: never;
}[TableDefinedFieldNames<T>];
type TableDefinedEntity<T extends TableSchemaBuilder> = Pretty<
RequiredFields<T> &
OptionalFields<T> & {
[PrimaryKey in PrimaryKeyField<T>]: Typeof<T[PrimaryKey]>;
}
>;
type MaybeDocumentEntityWrapper<Schema, Ty> =
Schema extends DocumentTableSchemaBuilder
? Ty & {
[key: string]: any;
}
: Ty;
type NonPrimaryKeyFieldNames<T extends TableSchemaBuilder> = {
[K in TableDefinedFieldNames<T>]: T[K] extends FieldSchemaBuilder<
any,
any,
infer PrimaryKey
>
? PrimaryKey extends false
? K
: never
: never;
}[TableDefinedFieldNames<T>];
// CRUD api types
export type PrimaryKeyFieldType<T extends TableSchemaBuilder> = Typeof<
T[PrimaryKeyField<T>]
>;
export type CreateEntityInput<T extends TableSchemaBuilder> = Pretty<
MaybeDocumentEntityWrapper<T, RequiredFields<T> & OptionalFields<T>>
>;
// @TODO(@forehalo): return value need to be specified with `Default` inference
export type Entity<T extends TableSchemaBuilder> = Pretty<
MaybeDocumentEntityWrapper<T, TableDefinedEntity<T>>
>;
export type UpdateEntityInput<T extends TableSchemaBuilder> = Pretty<
MaybeDocumentEntityWrapper<
T,
{
[
key in NonPrimaryKeyFieldNames<T>
]?: key extends keyof TableDefinedEntity<T>
? TableDefinedEntity<T>[key]
: never;
}
>
>;
export type FindEntityInput<T extends TableSchemaBuilder> = Pretty<
MaybeDocumentEntityWrapper<
T,
{
[
key in TableDefinedFieldNames<T>
]?: key extends keyof TableDefinedEntity<T>
?
| TableDefinedEntity<T>[key]
| { not: TableDefinedEntity<T>[key] | null }
| null
: never;
}
>
>;
export class Table<T extends TableSchemaBuilder> {
readonly schema: TableSchema = {};
readonly keyField: string = '';
private readonly adapter: TableAdapter;
public readonly isDocumentTable: boolean = false;
private readonly subscribedKeys: Map<Key, Observable<any>> = new Map();
constructor(
db: DBAdapter,
public readonly name: string,
private readonly opts: TableOptions
) {
this.adapter = db.table(name) as any;
for (const [fieldName, fieldBuilder] of Object.entries(this.opts.schema)) {
// handle internal fields
if (fieldName.startsWith('__')) {
if (fieldName === '__document') {
this.isDocumentTable = true;
}
continue;
}
this.schema[fieldName] = fieldBuilder.schema;
if (fieldBuilder.schema.isPrimaryKey) {
this.keyField = fieldName;
}
}
this.adapter.setup({ ...opts, keyField: this.keyField });
}
create(input: CreateEntityInput<T>): Entity<T> {
const data = Object.entries(this.schema).reduce(
(acc, [key, schema]) => {
const inputVal = acc[key];
if (inputVal === undefined) {
if (schema.optional) {
acc[key] = undefined;
}
if (schema.default) {
acc[key] = schema.default() ?? undefined;
}
}
return acc;
},
omitBy(input, isUndefined) as any
);
validators.validateCreateEntityData(this, data);
return this.adapter.insert({
data,
});
}
update(
key: PrimaryKeyFieldType<T>,
input: UpdateEntityInput<T>
): Entity<T> | null {
validators.validateUpdateEntityData(this, input);
const [record] = this.adapter.update({
where: {
byKey: key,
},
data: input,
});
return record || null;
}
get(key: PrimaryKeyFieldType<T>): Entity<T> | null {
const [record] = this.adapter.find({
where: {
byKey: key,
},
});
return record || null;
}
get$(key: PrimaryKeyFieldType<T>): Observable<Entity<T> | null> {
let ob$ = this.subscribedKeys.get(key);
if (!ob$) {
ob$ = new Observable<Entity<T>>(subscriber => {
const unsubscribe = this.adapter.observe({
where: {
byKey: key,
},
callback: ([data]) => {
subscriber.next(data || null);
},
});
return () => {
unsubscribe();
this.subscribedKeys.delete(key);
};
}).pipe(
shareReplay({
refCount: true,
bufferSize: 1,
})
);
this.subscribedKeys.set(key, ob$);
}
return ob$;
}
find(where?: FindEntityInput<T>): Entity<T>[] {
return this.adapter.find({
where: !where
? undefined
: Object.entries(where)
.map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
});
}
find$(where?: FindEntityInput<T>): Observable<Entity<T>[]> {
return new Observable<Entity<T>[]>(subscriber => {
const unsubscribe = this.adapter.observe({
where: !where
? undefined
: Object.entries(where)
.map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
callback: data => {
subscriber.next(data);
},
});
return unsubscribe;
});
}
select<Key extends keyof Entity<T>>(
selectKey: Key,
where?: FindEntityInput<T>
): Pick<Entity<T>, Key | PrimaryKeyField<T>>[] {
const items = this.adapter.find({
where: !where
? undefined
: Object.entries(where)
.map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
});
return items.map(item => {
const { [this.keyField]: key, [selectKey]: selected } = item;
return {
[this.keyField]: key,
[selectKey]: selected,
} as Pick<Entity<T>, Key | PrimaryKeyField<T>>;
});
}
select$<Key extends keyof Entity<T>>(
selectKey: Key,
where?: FindEntityInput<T>
): Observable<Pick<Entity<T>, Key | PrimaryKeyField<T>>[]> {
return new Observable(subscriber => {
const unsubscribe = this.adapter.observe({
where: !where
? undefined
: Object.entries(where)
.map(([field, value]) => ({
field,
value,
}))
.filter(({ value }) => value !== undefined),
select: [this.keyField, selectKey as string],
callback: data => {
subscriber.next(
data.map(item => {
const { [this.keyField]: key, [selectKey]: selected } = item;
return {
[this.keyField]: key,
[selectKey]: selected,
} as Pick<Entity<T>, Key | PrimaryKeyField<T>>;
})
);
},
});
return unsubscribe;
});
}
keys(): PrimaryKeyFieldType<T>[] {
return this.adapter.find({
select: 'key',
});
}
keys$(): Observable<PrimaryKeyFieldType<T>[]> {
let ob$ = this.subscribedKeys.get('$$KEYS');
if (!ob$) {
ob$ = new Observable<PrimaryKeyFieldType<T>[]>(subscriber => {
const unsubscribe = this.adapter.observe({
select: 'key',
callback: (keys: PrimaryKeyFieldType<T>[]) => {
subscriber.next(keys);
},
});
return () => {
unsubscribe();
this.subscribedKeys.delete('$$KEYS');
};
}).pipe(
shareReplay({
refCount: true,
bufferSize: 1,
})
);
this.subscribedKeys.set('$$KEYS', ob$);
}
return ob$;
}
delete(key: PrimaryKeyFieldType<T>) {
this.adapter.delete({
where: {
byKey: key,
},
});
}
}
export type TableMap<Tables extends DBSchemaBuilder> = {
readonly [K in keyof Tables]: Table<Tables[K]>;
};

View File

@@ -0,0 +1,9 @@
import type { TableSchemaBuilder } from './schema';
export interface Key {
toString(): string;
}
export interface TableOptions {
schema: TableSchemaBuilder;
}

View File

@@ -0,0 +1,160 @@
import { pick as lodashPick } from 'lodash-es';
import type { FieldType } from '../schema';
import type { DataValidator } from './types';
function inputType(val: any) {
return val === null ||
Array.isArray(val) ||
val.constructor === Object ||
!val.constructor /* Object.create(null) */
? 'json'
: typeof val;
}
function typeMatches(typeWant: FieldType, typeGet: string) {
if (typeWant === 'json') {
switch (typeGet) {
case 'bigint':
case 'function':
case 'object': // we've already converted available types into 'json'
case 'symbol':
case 'undefined':
return false;
}
}
return typeWant === typeGet;
}
export const dataValidators = {
PrimaryKeyShouldExist: {
validate(table, data) {
const val = data[table.keyField];
if (val === undefined || val === null) {
throw new Error(
`[Table(${table.name})]: Primary key field '${table.keyField}' is required but not set.`
);
}
},
},
PrimaryKeyShouldNotBeUpdated: {
validate(table, data) {
if (data[table.keyField] !== undefined) {
throw new Error(
`[Table(${table.name})]: Primary key field '${table.keyField}' can't be updated.`
);
}
},
},
DataTypeShouldMatch: {
validate(table, data) {
for (const key in data) {
const field = table.schema[key];
if (field) {
const val = data[key];
if (val === undefined) {
delete data[key];
continue;
}
if (val === null) {
if (!field.optional) {
throw new Error(
`[Table(${table.name})]: Field '${key}' is required but not set.`
);
}
continue;
}
const typeGet = inputType(val);
if (field.type === 'enum') {
if (!field.values?.includes(val)) {
throw new Error(
`[Table(${table.name})]: Field '${key}' value '${val}' is not valid. Expected one of [${field.values?.join(', ')}].`
);
}
} else if (!typeMatches(field.type, typeGet)) {
throw new Error(
`[Table(${table.name})]: Field '${key}' type mismatch. Expected ${field.type} got ${typeGet}.`
);
}
} else if (!table.isDocumentTable) {
// strict check field existence for normal table
throw new Error(
`[Table(${table.name})]: Field '${key}' is not defined but set in entity.`
);
}
}
},
},
DataTypeShouldExactlyMatch: {
validate(table, data) {
const keys: Set<string> = new Set();
for (const key in data) {
const field = table.schema[key];
if (field) {
const val = data[key];
if (val === undefined || val === null) {
if (!field.optional) {
throw new Error(
`[Table(${table.name})]: Field '${key}' is required but not set.`
);
}
continue;
}
const typeGet = inputType(val);
if (field.type === 'enum') {
if (!field.values?.includes(val)) {
throw new Error(
`[Table(${table.name})]: Field '${key}' value '${val}' is not valid. Expected one of [${field.values?.join(', ')}].`
);
}
} else if (!typeMatches(field.type, typeGet)) {
throw new Error(
`[Table(${table.name})]: Field '${key}' type mismatch. Expected type '${field.type}' but got '${typeGet}'.`
);
}
keys.add(key);
} else if (!table.isDocumentTable) {
// strict check field existence for normal table
throw new Error(
`[Table(${table.name})]: Field '${key}' is not defined but set in entity.`
);
}
}
for (const key in table.schema) {
if (!keys.has(key) && table.schema[key].optional === false) {
throw new Error(
`[Table(${table.name})]: Field '${key}' is required but not set.`
);
}
}
},
},
} satisfies Record<string, DataValidator>;
// lodash pick's signature is not typesafe
const pick = lodashPick as <T extends Record<string, any>>(
obj: T,
...keys: Array<keyof T>
) => Pick<T, keyof T>;
export const createEntityDataValidators = pick(
dataValidators,
'PrimaryKeyShouldExist',
'DataTypeShouldExactlyMatch'
);
export const updateEntityDataValidators = pick(
dataValidators,
'PrimaryKeyShouldNotBeUpdated',
'DataTypeShouldMatch'
);

View File

@@ -0,0 +1,50 @@
import { createEntityDataValidators, updateEntityDataValidators } from './data';
import { tableSchemaValidators } from './schema';
import { yjsDataValidators, yjsTableSchemaValidators } from './yjs';
interface ValidationError {
code: string;
error: Error;
}
function throwIfError(errors: ValidationError[]) {
if (errors.length) {
const message = errors
.map(({ code, error }) => `${code}: ${error.stack ?? error.message}`)
.join('\n');
throw new Error('Validation Failed Error\n' + message);
}
}
function validate<Validator extends { validate: (...args: any[]) => void }>(
rules: Record<string, Validator>,
...payload: Parameters<Validator['validate']>
) {
const errors: ValidationError[] = [];
for (const [code, validator] of Object.entries(rules)) {
try {
validator.validate(...payload);
} catch (e) {
errors.push({ code, error: e as Error });
}
}
throwIfError(errors);
}
function use<Validator extends { validate: (...args: any[]) => void }>(
rules: Record<string, Validator>
) {
return (...payload: Parameters<Validator['validate']>) =>
validate(rules, ...payload);
}
export const validators = {
validateTableSchema: use(tableSchemaValidators),
validateCreateEntityData: use(createEntityDataValidators),
validateUpdateEntityData: use(updateEntityDataValidators),
validateYjsTableSchema: use(yjsTableSchemaValidators),
validateYjsEntityData: use(yjsDataValidators),
};

View File

@@ -0,0 +1,42 @@
import type { TableSchemaValidator } from './types';
export const tableSchemaValidators: Record<string, TableSchemaValidator> = {
PrimaryKeyShouldExist: {
validate(tableName, table) {
if (!Object.values(table).some(field => field.schema.isPrimaryKey)) {
throw new Error(
`[Table(${tableName})]: There should be at least one field marked as primary key.`
);
}
},
},
OnlyOnePrimaryKey: {
validate(tableName, table) {
const primaryFields = [];
for (const name in table) {
if (table[name].schema.isPrimaryKey) {
primaryFields.push(name);
}
}
if (primaryFields.length > 1) {
throw new Error(
`[Table(${tableName})]: There should be only one field marked as primary key. Found [${primaryFields.join(', ')}].`
);
}
},
},
PrimaryKeyShouldNotBeOptional: {
validate(tableName, table) {
for (const name in table) {
const opts = table[name].schema;
if (opts.isPrimaryKey && opts.optional && !opts.default) {
throw new Error(
`[Table(${tableName})]: Field '${name}' can't be marked primary key and optional with no default value provider at the same time.`
);
}
}
},
},
};

View File

@@ -0,0 +1,10 @@
import type { TableSchemaBuilder } from '../schema';
import type { Table } from '../table';
export interface TableSchemaValidator {
validate(tableName: string, schema: TableSchemaBuilder): void;
}
export interface DataValidator {
validate(table: Table<any>, data: any): void;
}

View File

@@ -0,0 +1,35 @@
import type { TableSchemaValidator } from './types';
const PRESERVED_FIELDS = ['$$DELETED'];
interface DataValidator {
validate(tableName: string, data: any): void;
}
export const yjsTableSchemaValidators: Record<string, TableSchemaValidator> = {
UsePreservedFields: {
validate(tableName, table) {
for (const name in table) {
if (PRESERVED_FIELDS.includes(name)) {
throw new Error(
`[Table(${tableName})]: Field '${name}' is reserved keyword and can't be used.`
);
}
}
},
},
};
export const yjsDataValidators: Record<string, DataValidator> = {
SetPreservedFields: {
validate(tableName, data) {
for (const name of PRESERVED_FIELDS) {
if (data[name] !== undefined) {
throw new Error(
`[Table(${tableName})]: Field '${name}' is reserved keyword and can't be set.`
);
}
}
},
},
};

View File

@@ -0,0 +1,11 @@
export type {
DBSchemaBuilder,
FieldSchemaBuilder,
ORMClient,
Entity as ORMEntity,
Table,
TableMap,
TableSchemaBuilder,
UpdateEntityInput,
} from './core';
export { createORMClient, f, t, YjsDBAdapter } from './core';

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { MemoryMemento } from '..';
describe('memento', () => {
test('memory', () => {
const memento = new MemoryMemento();
expect(memento.get('foo')).toBeUndefined();
memento.set('foo', 'bar');
expect(memento.get('foo')).toEqual('bar');
let subscribed = null;
const subscription = memento.watch('foo').subscribe(v => {
subscribed = v;
});
expect(subscribed).toEqual('bar');
memento.set('foo', 'baz');
expect(subscribed).toEqual('baz');
subscription.unsubscribe();
memento.set('foo', 'hello');
expect(subscribed).toEqual('baz');
});
});

View File

@@ -0,0 +1,10 @@
import { type Observable } from 'rxjs';
export interface AsyncMemento {
watch<T>(key: string): Observable<T | undefined>;
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T | undefined): Promise<void>;
del(key: string): Promise<void>;
clear(): Promise<void>;
keys(): Promise<string[]>;
}

View File

@@ -0,0 +1,3 @@
export * from './async-memento';
export * from './kv';
export * from './memento';

View File

@@ -0,0 +1,85 @@
import { AsyncLock } from '../utils';
export interface ByteKV extends ByteKVBehavior {
transaction<T>(cb: (transaction: ByteKVBehavior) => Promise<T>): Promise<T>;
}
export interface ByteKVBehavior {
get(key: string): Promise<Uint8Array | null> | Uint8Array | null;
set(key: string, value: Uint8Array): Promise<void> | void;
del(key: string): Promise<void> | void;
keys(): Promise<string[]> | string[];
clear(): Promise<void> | void;
}
export class MemoryByteKV implements ByteKV {
readonly lock = new AsyncLock();
constructor(readonly db = new Map<string, Uint8Array>()) {}
async transaction<T>(cb: (transaction: ByteKVBehavior) => Promise<T>) {
using _lock = await this.lock.acquire();
return await cb({
get: async key => {
return this.db.get(key) ?? null;
},
set: async (key, value) => {
this.db.set(key, value);
},
keys: async () => {
return Array.from(this.db.keys());
},
del: async key => {
this.db.delete(key);
},
clear: async () => {
this.db.clear();
},
});
}
get(key: string) {
return this.transaction(async tx => tx.get(key));
}
set(key: string, value: Uint8Array) {
return this.transaction(async tx => tx.set(key, value));
}
keys() {
return this.transaction(async tx => tx.keys());
}
clear() {
return this.transaction(async tx => tx.clear());
}
del(key: string) {
return this.transaction(async tx => tx.del(key));
}
}
export class ReadonlyByteKV extends MemoryByteKV implements ByteKV {
override transaction<T>(
cb: (transaction: ByteKVBehavior) => Promise<T>
): Promise<T> {
return super.transaction(tx => {
return cb({
...tx,
set() {
return Promise.resolve();
},
del() {
return Promise.resolve();
},
clear() {
return Promise.resolve();
},
});
});
}
override set(_key: string, _value: Uint8Array): Promise<void> {
return Promise.resolve();
}
override del(_key: string): Promise<void> {
return Promise.resolve();
}
override clear(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -0,0 +1,88 @@
import type { Observable } from 'rxjs';
import { LiveData } from '../livedata';
/**
* A memento represents a storage utility. It can store and retrieve values, and observe changes.
*/
export interface Memento {
get<T>(key: string): T | undefined;
watch<T>(key: string): Observable<T | undefined>;
set<T>(key: string, value: T | undefined): void;
del(key: string): void;
clear(): void;
keys(): string[];
}
/**
* A simple implementation of Memento. Used for testing.
*/
export class MemoryMemento implements Memento {
private readonly data = new Map<string, LiveData<any>>();
setAll(init: Record<string, any>) {
for (const [key, value] of Object.entries(init)) {
this.set(key, value);
}
}
private getLiveData(key: string): LiveData<any> {
let data$ = this.data.get(key);
if (!data$) {
data$ = new LiveData<any>(undefined);
this.data.set(key, data$);
}
return data$;
}
get<T>(key: string): T | undefined {
return this.getLiveData(key).value;
}
watch<T>(key: string): Observable<T | undefined> {
return this.getLiveData(key).asObservable();
}
set<T>(key: string, value: T): void {
this.getLiveData(key).next(value);
}
keys(): string[] {
return Array.from(this.data)
.filter(([_, v$]) => v$.value !== undefined)
.map(([k]) => k);
}
clear(): void {
this.data.clear();
}
del(key: string): void {
this.data.delete(key);
}
}
export function wrapMemento(memento: Memento, prefix: string): Memento {
return {
get<T>(key: string): T | undefined {
return memento.get(prefix + key);
},
watch(key: string) {
return memento.watch(prefix + key);
},
set<T>(key: string, value: T): void {
memento.set(prefix + key, value);
},
keys(): string[] {
return memento
.keys()
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length));
},
clear() {
memento.keys().forEach(k => {
if (k.startsWith(prefix)) {
memento.del(k);
}
});
},
del(key: string): void {
memento.del(prefix + key);
},
};
}

View File

@@ -0,0 +1,113 @@
import { describe, expect, test } from 'vitest';
import { AsyncLock } from '../async-lock';
describe('AsyncLock', () => {
test('should acquire and release lock', async () => {
const lock = new AsyncLock();
const lock1 = await lock.acquire();
lock1.release();
});
test('should wait for previous lock to be released', async () => {
const lock = new AsyncLock();
const order: number[] = [];
const task1 = async () => {
const lock1 = await lock.acquire();
order.push(1);
await new Promise(resolve => setTimeout(resolve, 10));
order.push(2);
lock1.release();
};
const task2 = async () => {
const lock2 = await lock.acquire();
order.push(3);
lock2.release();
};
await Promise.all([task1(), task2()]);
expect(order).toEqual([1, 2, 3]);
});
test('should work with using statement', async () => {
const lock = new AsyncLock();
const order: number[] = [];
const task1 = async () => {
using _lock1 = await lock.acquire();
order.push(1);
await new Promise(resolve => setTimeout(resolve, 10));
order.push(2);
};
const task2 = async () => {
using _lock2 = await lock.acquire();
order.push(3);
};
await Promise.all([task1(), task2()]);
expect(order).toEqual([1, 2, 3]);
});
test('should handle multiple concurrent locks', async () => {
const lock = new AsyncLock();
const results: number[] = [];
const createTask = (id: number, delay: number) => async () => {
using _lockHandle = await lock.acquire();
results.push(id);
await new Promise(resolve => setTimeout(resolve, delay));
};
await Promise.all([
createTask(1, 20)(),
createTask(2, 10)(),
createTask(3, 5)(),
]);
expect(results).toEqual([1, 2, 3]);
});
test('should properly block after a release', async () => {
const lock = new AsyncLock();
const order: number[] = [];
// First acquisition
const lock1 = await lock.acquire();
order.push(1);
lock1.release();
// These two should be properly serialized
const task1 = async () => {
const lock2 = await lock.acquire();
order.push(2);
await new Promise(resolve => setTimeout(resolve, 10));
order.push(3);
lock2.release();
};
const task2 = async () => {
const lock3 = await lock.acquire();
order.push(4);
lock3.release();
};
await Promise.all([task1(), task2()]);
expect(order).toEqual([1, 2, 3, 4]); // This might fail due to the bug
});
test('should prevent multiple releases', async () => {
const lock = new AsyncLock();
const handle = await lock.acquire();
handle.release();
// This second release should either throw or be a no-op
handle.release();
// The lock should still be usable
const handle2 = await lock.acquire();
handle2.release();
});
});

View File

@@ -0,0 +1,51 @@
import { describe, expect, test, vi } from 'vitest';
import { AsyncQueue } from '../async-queue';
describe('async-queue', () => {
test('push & pop', async () => {
const queue = new AsyncQueue();
queue.push(1, 2, 3);
expect(queue.length).toBe(3);
expect(await queue.next()).toBe(1);
expect(await queue.next()).toBe(2);
expect(await queue.next()).toBe(3);
expect(queue.length).toBe(0);
});
test('await', async () => {
const queue = new AsyncQueue<number>();
queue.push(1, 2);
expect(await queue.next()).toBe(1);
expect(await queue.next()).toBe(2);
let v = -1;
// setup 2 pop tasks
queue
.next()
.then(next => {
v = next;
})
.catch(console.error);
queue
.next()
.then(next => {
v = next;
})
.catch(console.error);
// Wait for 100ms
await new Promise(resolve => setTimeout(resolve, 100));
// v should not be changed
expect(v).toBe(-1);
// push 3, should trigger the first pop task
queue.push(3);
await vi.waitFor(() => v === 3);
// push 4, should trigger the second pop task
queue.push(4);
await vi.waitFor(() => v === 4);
});
});

View File

@@ -0,0 +1,74 @@
import { expect, test } from 'vitest';
import { generateFractionalIndexingKeyBetween } from '../fractional-indexing';
function gen(a: string | null, b: string | null) {
const result = generateFractionalIndexingKeyBetween(a, b);
expect(
a === null || b === null || (a < result && result < b),
`${a} ${b} ${result}`
).toBe(true);
return result;
}
test('fractional-indexing', () => {
for (let i = 0; i < 100; i++) {
const set = new Set<string>();
let a = null;
let b = null;
for (let i = 0; i < 100; i++) {
const s1 = gen(a, b);
expect(a === null || b === null || (a < s1 && s1 < b)).toBe(true);
const s2 = gen(a, b);
expect(a === null || b === null || (a < s2 && s2 < b)).toBe(true);
if (set.has(s1) || set.has(s2) || s1 === s2) {
throw new Error('Duplicate key, ' + set.size + ', ' + s1 + ', ' + s2);
}
set.add(s1);
set.add(s2);
if (s1 < s2) {
a = s1;
b = s2;
} else {
a = s2;
b = s1;
}
}
}
});
test('no postfix', () => {
expect(
generateFractionalIndexingKeyBetween('a0', null).startsWith('a1')
).toBe(true);
expect(
generateFractionalIndexingKeyBetween('a01', null).startsWith('a1')
).toBe(true);
expect(
generateFractionalIndexingKeyBetween('a0001', null).startsWith('a1')
).toBe(true);
expect(
generateFractionalIndexingKeyBetween(null, 'a0').startsWith('Zz')
).toBe(true);
expect(
generateFractionalIndexingKeyBetween('a0', 'a01').startsWith('a00V')
).toBe(true);
});
test('edge cases', () => {
for (let i = 0; i < 100; i++) {
let a = generateFractionalIndexingKeyBetween(null, null);
let b = generateFractionalIndexingKeyBetween(null, null);
if (a > b) {
[a, b] = [b, a];
}
const c = generateFractionalIndexingKeyBetween(a, b);
const d = generateFractionalIndexingKeyBetween(c, b);
expect(a < c).toBeTruthy();
expect(c < b).toBeTruthy();
expect(c < d).toBeTruthy();
expect(d < b).toBeTruthy();
}
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from 'vitest';
import { isAllowedRedirectTarget } from '../redirect-allowlist';
describe('redirect allowlist', () => {
test('allows same hostname', () => {
expect(
isAllowedRedirectTarget('https://self.example.com/path', {
currentHostname: 'self.example.com',
})
).toBe(true);
});
test('allows trusted domains and subdomains', () => {
expect(
isAllowedRedirectTarget('https://github.com/toeverything/AFFiNE', {
currentHostname: 'self.example.com',
})
).toBe(true);
expect(
isAllowedRedirectTarget('https://sub.github.com/foo', {
currentHostname: 'self.example.com',
})
).toBe(true);
});
test('blocks look-alike domains', () => {
expect(
isAllowedRedirectTarget('https://evilgithub.com', {
currentHostname: 'self.example.com',
})
).toBe(false);
});
test('blocks disallowed protocols', () => {
expect(
isAllowedRedirectTarget('javascript:alert(1)', {
currentHostname: 'self.example.com',
})
).toBe(false);
});
test('handles port and trailing dot', () => {
expect(
isAllowedRedirectTarget('https://github.com:8443', {
currentHostname: 'self.example.com',
})
).toBe(true);
expect(
isAllowedRedirectTarget('https://affine.pro./', {
currentHostname: 'self.example.com',
})
).toBe(true);
});
test('blocks punycode homograph', () => {
// "а" is Cyrillic small a (U+0430), different from Latin "a"
expect(
isAllowedRedirectTarget('https://аffine.pro', {
currentHostname: 'self.example.com',
})
).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, test } from 'vitest';
import { throwIfAborted } from '../throw-if-aborted';
describe('throw-if-aborted', () => {
test('basic', async () => {
const abortController = new AbortController();
const abortSignal = abortController.signal;
expect(throwIfAborted(abortSignal)).toBe(true);
abortController.abort('TEST_ABORT');
expect(() => throwIfAborted(abortSignal)).toThrowError('TEST_ABORT');
});
});

View File

@@ -0,0 +1,111 @@
import { describe, expect, test } from 'vitest';
import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
import { yjsGetPath, yjsObservePath } from '../yjs-observable';
describe('yjs observable', () => {
test('basic', async () => {
const ydoc = new YDoc();
let currentValue: any = false;
yjsGetPath(ydoc.getMap('foo'), 'key.subkey').subscribe(
v => (currentValue = v)
);
expect(currentValue).toBe(undefined);
ydoc.getMap('foo').set('key', new YMap([['subkey', 'xxxzzz']]));
expect(currentValue).toBe('xxxzzz');
(ydoc.getMap('foo').get('key') as YMap<string>).set('subkey', 'yyy');
expect(currentValue).toBe('yyy');
(ydoc.getMap('foo').get('key') as YMap<string>).delete('subkey');
expect(currentValue).toBe(undefined);
(ydoc.getMap('foo').get('key') as YMap<string>).set('subkey', 'yyy');
ydoc.getMap('foo').delete('key');
expect(currentValue).toBe(undefined);
ydoc.getMap('foo').set('key', 'text');
expect(currentValue).toBe(undefined);
});
test('observe with path', async () => {
const ydoc = new YDoc();
/**
* {
* metas: {
* pages: [
* {
* id: '1',
* title: 'page 1',
* tags: ['tag1', 'tag2']
* }
* ]
* }
* }
*/
let currentValue: any = false;
let callbackCount = 0;
yjsObservePath(ydoc.getMap('metas'), 'pages.*.tags').subscribe(v => {
callbackCount++;
currentValue = (v as any)
.toJSON()
.pages?.map((page: any) => ({ id: page.id, tags: page.tags ?? [] }));
});
expect(callbackCount).toBe(1);
ydoc.getMap('metas').set('pages', new YArray<any>());
expect(callbackCount).toBe(2);
expect(currentValue).toStrictEqual([]);
const pages = ydoc.getMap('metas').get('pages') as YArray<any>;
pages.push([
new YMap([
['id', '1'],
['title', 'page 1'],
['tags', YArray.from(['tag1', 'tag2'])],
]),
]);
expect(callbackCount).toBe(3);
expect(currentValue).toStrictEqual([{ id: '1', tags: ['tag1', 'tag2'] }]);
pages.get(0).set('title', 'page 1*');
expect(callbackCount).toBe(3); // no change
pages.get(0).get('tags').push(['tag3']);
expect(callbackCount).toBe(4);
expect(currentValue).toStrictEqual([
{ id: '1', tags: ['tag1', 'tag2', 'tag3'] },
]);
ydoc.getMap('metas').set('otherMeta', 'true');
expect(callbackCount).toBe(4); // no change
pages.push([
new YMap([
['id', '2'],
['title', 'page 2'],
]),
]);
expect(callbackCount).toBe(5);
expect(currentValue).toStrictEqual([
{ id: '1', tags: ['tag1', 'tag2', 'tag3'] },
{ id: '2', tags: [] },
]);
pages.delete(0);
expect(callbackCount).toBe(6);
expect(currentValue).toStrictEqual([{ id: '2', tags: [] }]);
});
});

View File

@@ -0,0 +1,36 @@
export class AsyncLock {
private _lock: Promise<void> | null = null;
async acquire() {
let release: (() => void) | null = null;
const nextLock = new Promise<void>(resolve => {
release = () => {
this._lock = null;
resolve();
};
});
// Atomic check and set of lock state
const currentLock = this._lock;
this._lock = nextLock;
if (currentLock) {
await currentLock;
}
return {
release: () => {
if (release) {
release();
release = null;
}
},
[Symbol.dispose]: () => {
if (release) {
release();
release = null;
}
},
};
}
}

View File

@@ -0,0 +1,101 @@
export class AsyncQueue<T> {
private _queue: T[];
private _resolveUpdate: (() => void) | null = null;
private _waitForUpdate: Promise<void> | null = null;
constructor(init: T[] = []) {
this._queue = init;
}
get length() {
return this._queue.length;
}
async next(
abort?: AbortSignal,
dequeue: (arr: T[]) => T | undefined = a => a.shift()
): Promise<T> {
const update = dequeue(this._queue);
if (update) {
return update;
} else {
if (!this._waitForUpdate) {
this._waitForUpdate = new Promise(resolve => {
this._resolveUpdate = resolve;
});
}
await Promise.race([
this._waitForUpdate,
new Promise((_, reject) => {
if (abort?.aborted) {
reject(abort?.reason);
}
abort?.addEventListener('abort', () => {
reject(abort.reason);
});
}),
]);
return this.next(abort, dequeue);
}
}
push(...updates: T[]) {
this._queue.push(...updates);
if (this._resolveUpdate) {
const resolve = this._resolveUpdate;
this._resolveUpdate = null;
this._waitForUpdate = null;
resolve();
}
}
remove(predicate: (update: T) => boolean) {
const index = this._queue.findIndex(predicate);
if (index !== -1) {
this._queue.splice(index, 1);
}
}
find(predicate: (update: T) => boolean) {
return this._queue.find(predicate);
}
clear() {
this._queue = [];
}
}
export class PriorityAsyncQueue<
T extends { id: string },
> extends AsyncQueue<T> {
constructor(
init: T[] = [],
public readonly priorityTarget: SharedPriorityTarget = new SharedPriorityTarget()
) {
super(init);
}
override next(abort?: AbortSignal | undefined): Promise<T> {
return super.next(abort, arr => {
if (this.priorityTarget.priorityRule !== null) {
const index = arr.findIndex(update =>
this.priorityTarget.priorityRule?.(update.id)
);
if (index !== -1) {
return arr.splice(index, 1)[0];
}
}
return arr.shift();
});
}
}
/**
* Shared priority target can be shared by multiple queues.
*/
export class SharedPriorityTarget {
public priorityRule: ((id: string) => boolean) | null = null;
}

View File

@@ -0,0 +1,41 @@
import {
asyncScheduler,
defer,
exhaustMap,
finalize,
type Observable,
type ObservableInput,
type OperatorFunction,
scheduled,
Subject,
throttle,
} from 'rxjs';
/**
* Like exhaustMap, but also includes the trailing value emitted from the source observable while waiting for the preceding inner observable to complete
*
* Original code adapted from https://github.com/ReactiveX/rxjs/issues/5004
* @param {function<T, K>(value: T, ?index: number): ObservableInput<K>} project - A function that, when applied to an item emitted by the
* source Observable, returns a projected Observable.
*/
export function exhaustMapWithTrailing<T, R>(
project: (value: T, index: number) => ObservableInput<R>
): OperatorFunction<T, R> {
return (source$): Observable<R> =>
defer(() => {
const release$ = new Subject<void>();
return source$.pipe(
throttle(() => release$, {
leading: true,
trailing: true,
}),
exhaustMap((value, index) =>
scheduled(project(value, index), asyncScheduler).pipe(
finalize(() => {
release$.next();
})
)
)
);
});
}

View File

@@ -0,0 +1,74 @@
import { generateKeyBetween } from 'fractional-indexing';
function hasSamePrefix(a: string, b: string) {
return a.startsWith(b) || b.startsWith(a);
}
/**
* generate a key between a and b, the result key is always satisfied with a < result < b.
* the key always has a random suffix, so there is no need to worry about collision.
*
* make sure a and b are generated by this function.
*
* @param customPostfix custom postfix for the key, only letters and numbers are allowed
*/
export function generateFractionalIndexingKeyBetween(
a: string | null,
b: string | null
) {
const randomSize = 32;
function postfix(length: number = randomSize) {
const chars =
'123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const values = new Uint8Array(length);
crypto.getRandomValues(values);
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(values[i] % chars.length);
}
return result;
}
if (a !== null && b !== null && a >= b) {
throw new Error('a should be smaller than b');
}
// get the subkey in full key
// e.g.
// a0xxxx -> a
// a0x0xxxx -> a0x
function subkey(key: string | null) {
if (key === null) {
return null;
}
if (key.length <= randomSize + 1) {
// no subkey
return key;
}
const splitAt = key.substring(0, key.length - randomSize - 1);
return splitAt;
}
const aSubkey = subkey(a);
const bSubkey = subkey(b);
if (aSubkey === null && bSubkey === null) {
// generate a new key
return generateKeyBetween(null, null) + '0' + postfix();
} else if (aSubkey === null && bSubkey !== null) {
// generate a key before b
return generateKeyBetween(null, bSubkey) + '0' + postfix();
} else if (bSubkey === null && aSubkey !== null) {
// generate a key after a
return generateKeyBetween(aSubkey, null) + '0' + postfix();
} else if (aSubkey !== null && bSubkey !== null) {
// generate a key between a and b
if (hasSamePrefix(aSubkey, bSubkey) && a !== null && b !== null) {
// conflict, if the subkeys are the same, generate a key between fullkeys
return generateKeyBetween(a, b) + '0' + postfix();
} else {
return generateKeyBetween(aSubkey, bSubkey) + '0' + postfix();
}
}
throw new Error('Never reach here');
}

View File

@@ -0,0 +1,10 @@
export * from './async-lock';
export * from './async-queue';
export * from './exhaustmap-with-trailing';
export * from './fractional-indexing';
export * from './merge-updates';
export * from './object-pool';
export * from './redirect-allowlist';
export * from './stable-hash';
export * from './throw-if-aborted';
export * from './yjs-observable';

View File

@@ -0,0 +1,17 @@
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
export function mergeUpdates(updates: Uint8Array[]) {
if (updates.length === 0) {
return new Uint8Array();
}
if (updates.length === 1) {
return updates[0];
}
const doc = new Doc();
doc.transact(() => {
updates.forEach(update => {
applyUpdate(doc, update);
});
});
return encodeStateAsUpdate(doc);
}

View File

@@ -0,0 +1,99 @@
import { Unreachable } from '@affine/env/constant';
export interface RcRef<T> extends Disposable {
obj: T;
release: () => void;
}
export class ObjectPool<Key, T> {
objects = new Map<Key, { obj: T; rc: number }>();
timeoutToGc: NodeJS.Timeout | null = null;
constructor(
private readonly options: {
onDelete?: (obj: T) => void;
onDangling?: (obj: T) => boolean;
} = {}
) {}
get(key: Key): RcRef<T> | null {
const exist = this.objects.get(key);
if (exist) {
exist.rc++;
let released = false;
const release = () => {
// avoid double release
if (released) {
return;
}
released = true;
exist.rc--;
this.requestGc();
};
return {
obj: exist.obj,
release,
[Symbol.dispose]: release,
};
}
return null;
}
put(key: Key, obj: T) {
const ref = { obj, rc: 0 };
this.objects.set(key, ref);
const r = this.get(key);
if (!r) {
throw new Unreachable();
}
return r;
}
private requestGc() {
if (this.timeoutToGc) {
clearInterval(this.timeoutToGc);
}
// do gc every 1s
this.timeoutToGc = setInterval(() => {
this.gc();
}, 1000);
}
private gc() {
for (const [key, { obj, rc }] of new Map(
this
.objects /* clone the map, because the origin will be modified during iteration */
)) {
if (
rc === 0 &&
(!this.options.onDangling || this.options.onDangling(obj))
) {
this.options.onDelete?.(obj);
this.objects.delete(key);
}
}
for (const [_, { rc }] of this.objects) {
if (rc === 0) {
return;
}
}
// if all object has referrer, stop gc
if (this.timeoutToGc) {
clearInterval(this.timeoutToGc);
}
}
clear() {
for (const { obj } of this.objects.values()) {
this.options.onDelete?.(obj);
}
this.objects.clear();
}
}

View File

@@ -0,0 +1,50 @@
export const TRUSTED_REDIRECT_DOMAINS = [
'google.com',
'stripe.com',
'github.com',
'twitter.com',
'discord.gg',
'youtube.com',
't.me',
'reddit.com',
'affine.pro',
].map(d => d.toLowerCase());
export const ALLOWED_REDIRECT_PROTOCOLS = new Set(['http:', 'https:']);
function normalizeHostname(hostname: string) {
return hostname.toLowerCase().replace(/\.$/, '');
}
function hostnameMatchesDomain(hostname: string, domain: string) {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
export function isAllowedRedirectTarget(
redirectUri: string,
options: {
currentHostname: string;
}
) {
const currentHostname = normalizeHostname(options.currentHostname);
try {
const target = new URL(redirectUri);
if (!ALLOWED_REDIRECT_PROTOCOLS.has(target.protocol)) {
return false;
}
const hostname = normalizeHostname(target.hostname);
if (hostname === currentHostname) {
return true;
}
return TRUSTED_REDIRECT_DOMAINS.some(domain =>
hostnameMatchesDomain(hostname, domain)
);
} catch {
return false;
}
}

View File

@@ -0,0 +1,34 @@
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
export function shallowEqual(objA: any, objB: any) {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (const key of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, key) ||
!Object.is(objA[key], objB[key])
) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,59 @@
// copied from https://github.com/shuding/stable-hash
// Use WeakMap to store the object-key mapping so the objects can still be
// garbage collected. WeakMap uses a hashtable under the hood, so the lookup
// complexity is almost O(1).
const table = new WeakMap<object, string>();
// A counter of the key.
let counter = 0;
// A stable hash implementation that supports:
// - Fast and ensures unique hash properties
// - Handles unserializable values
// - Handles object key ordering
// - Generates short results
//
// This is not a serialization function, and the result is not guaranteed to be
// parsable.
export function stableHash(arg: any): string {
const type = typeof arg;
const constructor = arg && arg.constructor;
const isDate = constructor === Date;
if (Object(arg) === arg && !isDate && constructor !== RegExp) {
// Object/function, not null/date/regexp. Use WeakMap to store the id first.
// If it's already hashed, directly return the result.
let result = table.get(arg);
if (result) return result;
// Store the hash first for circular reference detection before entering the
// recursive `stableHash` calls.
// For other objects like set and map, we use this id directly as the hash.
result = ++counter + '~';
table.set(arg, result);
let index: any;
if (constructor === Array) {
// Array.
result = '@';
for (index = 0; index < arg.length; index++) {
result += stableHash(arg[index]) + ',';
}
table.set(arg, result);
} else if (constructor === Object) {
// Object, sort keys.
result = '#';
const keys = Object.keys(arg).sort();
while ((index = keys.pop() as string) !== undefined) {
if (arg[index] !== undefined) {
result += index + ':' + stableHash(arg[index]) + ',';
}
}
table.set(arg, result);
}
return result;
}
if (isDate) return arg.toJSON();
if (type === 'symbol') return arg.toString();
return type === 'string' ? JSON.stringify(arg) : '' + arg;
}

View File

@@ -0,0 +1,9 @@
// because AbortSignal.throwIfAborted is not available in abortcontroller-polyfill
export function throwIfAborted(abort?: AbortSignal) {
if (abort?.aborted) {
throw new Error(abort.reason);
}
return true;
}
export const MANUALLY_STOP = 'manually-stop';

View File

@@ -0,0 +1,201 @@
import { distinctUntilChanged, Observable, of, switchMap } from 'rxjs';
import {
AbstractType as YAbstractType,
Array as YArray,
Map as YMap,
YArrayEvent,
type YEvent,
YMapEvent,
} from 'yjs';
/**
*
* @param path key.[0].key2.[1]
*/
function parsePath(path: string): (string | number)[] {
const parts = path.split('.');
return parts.map(part => {
if (part.startsWith('[') && part.endsWith(']')) {
const token = part.slice(1, -1);
if (token === '*') {
return '*';
}
const index = parseInt(token, 10);
if (isNaN(index)) {
throw new Error(`index: ${part} is not a number`);
}
return index;
}
return part;
});
}
function _yjsDeepWatch(
target: any,
path: ReturnType<typeof parsePath>
): Observable<unknown | undefined> {
if (path.length === 0) {
return of(target);
}
const current = path[0];
if (target instanceof YArray || target instanceof YMap) {
return new Observable(subscriber => {
const refresh = () => {
if (typeof current === 'number' && target instanceof YArray) {
subscriber.next(target.get(current));
} else if (typeof current === 'string' && target instanceof YMap) {
subscriber.next(target.get(current));
} else {
subscriber.next(undefined);
}
};
refresh();
target.observe(refresh);
return () => {
target.unobserve(refresh);
};
}).pipe(
distinctUntilChanged(),
switchMap(arr => _yjsDeepWatch(arr, path.slice(1)))
);
} else {
return of(undefined);
}
}
/**
* extract data from yjs type based on path, and return an observable.
* observable will automatically update when yjs data changed.
* if data is not exist on path, the observable will emit undefined.
*
* this function is optimized for deep watch performance.
*
* @example
* yjsGetPath(yjs, 'pages.[0].id') -> get pages[0].id and emit when changed
* yjsGetPath(yjs, 'pages.[0]').switchMap(yjsObserve) -> get pages[0] and emit when any of pages[0] or its children changed
* yjsGetPath(yjs, 'pages.[0]').switchMap(yjsObserveDeep) -> get pages[0] and emit when pages[0] or any of its deep children changed
*/
export function yjsGetPath(yjs: YAbstractType<any>, path: string) {
const parsedPath = parsePath(path);
return _yjsDeepWatch(yjs, parsedPath);
}
/**
* convert yjs type to observable.
* observable will automatically update when yjs data changed.
*
* @example
* yjsObserveDeep(yjs) -> emit when any of its deep children changed
*/
export function yjsObserveDeep(yjs?: any) {
return new Observable(subscriber => {
const refresh = () => {
subscriber.next(yjs);
};
refresh();
if (yjs instanceof YAbstractType) {
yjs.observeDeep(refresh);
return () => {
yjs.unobserveDeep(refresh);
};
}
return;
});
}
/**
* convert yjs type to observable.
* observable will automatically update when data changed on the path.
*
* @example
* yjsObservePath(yjs, 'pages.[0].id') -> only emit when pages[0].id changed
* yjsObservePath(yjs, 'pages.*.tags') -> only emit when tags of any page changed
*/
export function yjsObservePath(yjs?: any, path?: string) {
const parsedPath = path ? parsePath(path) : [];
return new Observable(subscriber => {
const refresh = (event?: YEvent<any>[]) => {
if (!event) {
subscriber.next(yjs);
return;
}
const changedPaths: (string | number)[][] = [];
event.forEach(e => {
if (e instanceof YArrayEvent) {
changedPaths.push(e.path);
} else if (e instanceof YMapEvent) {
for (const key of e.keysChanged) {
changedPaths.push([...e.path, key]);
}
}
});
for (const changedPath of changedPaths) {
let changed = true;
for (let i = 0; i < parsedPath.length; i++) {
const changedKey = changedPath[i];
const parsedKey = parsedPath[i];
if (changedKey === undefined) {
changed = true;
break;
}
if (parsedKey === undefined) {
changed = true;
break;
}
if (changedKey === parsedKey) {
continue;
}
if (parsedKey === '*') {
continue;
}
changed = false;
break;
}
if (changed) {
subscriber.next(yjs);
return;
}
}
};
refresh();
if (yjs instanceof YAbstractType) {
yjs.observeDeep(refresh);
return () => {
yjs.unobserveDeep(refresh);
};
}
return;
});
}
/**
* convert yjs type to observable.
* observable will automatically update when yjs data changed.
*
* @example
* yjsObserve(yjs) -> emit when yjs type changed
*/
export function yjsObserve(yjs?: any) {
return new Observable(subscriber => {
const refresh = () => {
subscriber.next(yjs);
};
refresh();
if (yjs instanceof YAbstractType) {
yjs.observe(refresh);
return () => {
yjs.unobserve(refresh);
};
}
return;
});
}

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.web.json",
"include": ["./src"],
"compilerOptions": {
"types": ["build-config", "affine__env", "node"],
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"references": [
{ "path": "../debug" },
{ "path": "../env" },
{ "path": "../error" }
]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["../../../typedoc.base.json"],
"entryPoints": ["src/index.ts"]
}