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
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:
92
packages/common/infra/src/app-config-storage.ts
Normal file
92
packages/common/infra/src/app-config-storage.ts
Normal 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> {}
|
||||
2
packages/common/infra/src/atom/index.ts
Normal file
2
packages/common/infra/src/atom/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './root-store';
|
||||
export * from './settings';
|
||||
5
packages/common/infra/src/atom/root-store.ts
Normal file
5
packages/common/infra/src/atom/root-store.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { getDefaultStore } from 'jotai';
|
||||
|
||||
export function getCurrentStore() {
|
||||
return getDefaultStore();
|
||||
}
|
||||
82
packages/common/infra/src/atom/settings.ts
Normal file
82
packages/common/infra/src/atom/settings.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
);
|
||||
539
packages/common/infra/src/framework/__tests__/framework.spec.ts
Normal file
539
packages/common/infra/src/framework/__tests__/framework.spec.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Component } from './component';
|
||||
|
||||
export class Entity<Props = {}> extends Component<Props> {
|
||||
readonly __isEntity = true;
|
||||
}
|
||||
42
packages/common/infra/src/framework/core/components/scope.ts
Normal file
42
packages/common/infra/src/framework/core/components/scope.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Component } from './component';
|
||||
|
||||
export class Service extends Component {
|
||||
readonly __isService = true;
|
||||
readonly __injectable = true;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Component } from './component';
|
||||
|
||||
export class Store extends Component {
|
||||
readonly __isStore = true;
|
||||
readonly __injectable = true;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
6
packages/common/infra/src/framework/core/consts.ts
Normal file
6
packages/common/infra/src/framework/core/consts.ts
Normal 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');
|
||||
59
packages/common/infra/src/framework/core/error.ts
Normal file
59
packages/common/infra/src/framework/core/error.ts
Normal 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(' -> ');
|
||||
}
|
||||
126
packages/common/infra/src/framework/core/event.ts
Normal file
126
packages/common/infra/src/framework/core/event.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
524
packages/common/infra/src/framework/core/framework.ts
Normal file
524
packages/common/infra/src/framework/core/framework.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
packages/common/infra/src/framework/core/identifier.ts
Normal file
114
packages/common/infra/src/framework/core/identifier.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
11
packages/common/infra/src/framework/core/index.ts
Normal file
11
packages/common/infra/src/framework/core/index.ts
Normal 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';
|
||||
382
packages/common/infra/src/framework/core/provider.ts
Normal file
382
packages/common/infra/src/framework/core/provider.ts
Normal 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
|
||||
}
|
||||
}
|
||||
5
packages/common/infra/src/framework/core/scope.ts
Normal file
5
packages/common/infra/src/framework/core/scope.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { FrameworkScopeStack } from './types';
|
||||
|
||||
export function stringifyScope(scope: FrameworkScopeStack): string {
|
||||
return scope.join('/');
|
||||
}
|
||||
35
packages/common/infra/src/framework/core/types.ts
Normal file
35
packages/common/infra/src/framework/core/types.ts
Normal 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;
|
||||
};
|
||||
2
packages/common/infra/src/framework/index.ts
Normal file
2
packages/common/infra/src/framework/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './core';
|
||||
export * from './react';
|
||||
81
packages/common/infra/src/framework/react/index.tsx
Normal file
81
packages/common/infra/src/framework/react/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
packages/common/infra/src/index.ts
Normal file
8
packages/common/infra/src/index.ts
Normal 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';
|
||||
337
packages/common/infra/src/livedata/__tests__/livedata.spec.ts
Normal file
337
packages/common/infra/src/livedata/__tests__/livedata.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
183
packages/common/infra/src/livedata/__tests__/react.spec.tsx
Normal file
183
packages/common/infra/src/livedata/__tests__/react.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
127
packages/common/infra/src/livedata/effect/index.ts
Normal file
127
packages/common/infra/src/livedata/effect/index.ts
Normal 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;
|
||||
}
|
||||
13
packages/common/infra/src/livedata/index.ts
Normal file
13
packages/common/infra/src/livedata/index.ts
Normal 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';
|
||||
593
packages/common/infra/src/livedata/livedata.ts
Normal file
593
packages/common/infra/src/livedata/livedata.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
206
packages/common/infra/src/livedata/ops.ts
Normal file
206
packages/common/infra/src/livedata/ops.ts
Normal 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);
|
||||
})
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
37
packages/common/infra/src/livedata/react.ts
Normal file
37
packages/common/infra/src/livedata/react.ts
Normal 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
|
||||
);
|
||||
}
|
||||
1
packages/common/infra/src/media/index.ts
Normal file
1
packages/common/infra/src/media/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './types';
|
||||
71
packages/common/infra/src/media/types.ts
Normal file
71
packages/common/infra/src/media/types.ts
Normal 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;
|
||||
}
|
||||
159
packages/common/infra/src/op/README.md
Normal file
159
packages/common/infra/src/op/README.md
Normal 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();
|
||||
});
|
||||
```
|
||||
300
packages/common/infra/src/op/__tests__/client.spec.ts
Normal file
300
packages/common/infra/src/op/__tests__/client.spec.ts
Normal 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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
266
packages/common/infra/src/op/__tests__/consumer.spec.ts
Normal file
266
packages/common/infra/src/op/__tests__/consumer.spec.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
75
packages/common/infra/src/op/__tests__/message.spec.ts
Normal file
75
packages/common/infra/src/op/__tests__/message.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
233
packages/common/infra/src/op/client.ts
Normal file
233
packages/common/infra/src/op/client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
281
packages/common/infra/src/op/consumer.ts
Normal file
281
packages/common/infra/src/op/consumer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
4
packages/common/infra/src/op/index.ts
Normal file
4
packages/common/infra/src/op/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './client';
|
||||
export * from './consumer';
|
||||
export { type MessageCommunicapable, transfer } from './message';
|
||||
export type { OpSchema } from './types';
|
||||
168
packages/common/infra/src/op/message.ts
Normal file
168
packages/common/infra/src/op/message.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
50
packages/common/infra/src/op/types.ts
Normal file
50
packages/common/infra/src/op/types.ts
Normal 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;
|
||||
127
packages/common/infra/src/orm/core/__tests__/doc.spec.ts
Normal file
127
packages/common/infra/src/orm/core/__tests__/doc.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
139
packages/common/infra/src/orm/core/__tests__/entity.spec.ts
Normal file
139
packages/common/infra/src/orm/core/__tests__/entity.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
130
packages/common/infra/src/orm/core/__tests__/hook.spec.ts
Normal file
130
packages/common/infra/src/orm/core/__tests__/hook.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
packages/common/infra/src/orm/core/__tests__/schema.spec.ts
Normal file
145
packages/common/infra/src/orm/core/__tests__/schema.spec.ts
Normal 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]."
|
||||
);
|
||||
});
|
||||
});
|
||||
635
packages/common/infra/src/orm/core/__tests__/yjs.spec.ts
Normal file
635
packages/common/infra/src/orm/core/__tests__/yjs.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
4
packages/common/infra/src/orm/core/adapters/index.ts
Normal file
4
packages/common/infra/src/orm/core/adapters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './memory/db';
|
||||
export * from './mixins';
|
||||
export * from './types';
|
||||
export * from './yjs/db';
|
||||
8
packages/common/infra/src/orm/core/adapters/memory/db.ts
Normal file
8
packages/common/infra/src/orm/core/adapters/memory/db.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
180
packages/common/infra/src/orm/core/adapters/memory/table.ts
Normal file
180
packages/common/infra/src/orm/core/adapters/memory/table.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
44
packages/common/infra/src/orm/core/adapters/mixins/hook.ts
Normal file
44
packages/common/infra/src/orm/core/adapters/mixins/hook.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './hook';
|
||||
76
packages/common/infra/src/orm/core/adapters/types.ts
Normal file
76
packages/common/infra/src/orm/core/adapters/types.ts
Normal 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;
|
||||
}
|
||||
35
packages/common/infra/src/orm/core/adapters/yjs/db.ts
Normal file
35
packages/common/infra/src/orm/core/adapters/yjs/db.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
296
packages/common/infra/src/orm/core/adapters/yjs/table.ts
Normal file
296
packages/common/infra/src/orm/core/adapters/yjs/table.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
65
packages/common/infra/src/orm/core/client.ts
Normal file
65
packages/common/infra/src/orm/core/client.ts
Normal 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;
|
||||
};
|
||||
4
packages/common/infra/src/orm/core/index.ts
Normal file
4
packages/common/infra/src/orm/core/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './adapters';
|
||||
export * from './client';
|
||||
export * from './schema';
|
||||
export * from './table';
|
||||
73
packages/common/infra/src/orm/core/schema.ts
Normal file
73
packages/common/infra/src/orm/core/schema.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
386
packages/common/infra/src/orm/core/table.ts
Normal file
386
packages/common/infra/src/orm/core/table.ts
Normal 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]>;
|
||||
};
|
||||
9
packages/common/infra/src/orm/core/types.ts
Normal file
9
packages/common/infra/src/orm/core/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { TableSchemaBuilder } from './schema';
|
||||
|
||||
export interface Key {
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export interface TableOptions {
|
||||
schema: TableSchemaBuilder;
|
||||
}
|
||||
160
packages/common/infra/src/orm/core/validators/data.ts
Normal file
160
packages/common/infra/src/orm/core/validators/data.ts
Normal 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'
|
||||
);
|
||||
50
packages/common/infra/src/orm/core/validators/index.ts
Normal file
50
packages/common/infra/src/orm/core/validators/index.ts
Normal 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),
|
||||
};
|
||||
42
packages/common/infra/src/orm/core/validators/schema.ts
Normal file
42
packages/common/infra/src/orm/core/validators/schema.ts
Normal 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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
10
packages/common/infra/src/orm/core/validators/types.ts
Normal file
10
packages/common/infra/src/orm/core/validators/types.ts
Normal 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;
|
||||
}
|
||||
35
packages/common/infra/src/orm/core/validators/yjs.ts
Normal file
35
packages/common/infra/src/orm/core/validators/yjs.ts
Normal 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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
11
packages/common/infra/src/orm/index.ts
Normal file
11
packages/common/infra/src/orm/index.ts
Normal 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';
|
||||
25
packages/common/infra/src/storage/__tests__/memento.spec.ts
Normal file
25
packages/common/infra/src/storage/__tests__/memento.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
10
packages/common/infra/src/storage/async-memento.ts
Normal file
10
packages/common/infra/src/storage/async-memento.ts
Normal 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[]>;
|
||||
}
|
||||
3
packages/common/infra/src/storage/index.ts
Normal file
3
packages/common/infra/src/storage/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './async-memento';
|
||||
export * from './kv';
|
||||
export * from './memento';
|
||||
85
packages/common/infra/src/storage/kv.ts
Normal file
85
packages/common/infra/src/storage/kv.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
88
packages/common/infra/src/storage/memento.ts
Normal file
88
packages/common/infra/src/storage/memento.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
113
packages/common/infra/src/utils/__tests__/async-lock.spec.ts
Normal file
113
packages/common/infra/src/utils/__tests__/async-lock.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
111
packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts
Normal file
111
packages/common/infra/src/utils/__tests__/yjs-observable.spec.ts
Normal 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: [] }]);
|
||||
});
|
||||
});
|
||||
36
packages/common/infra/src/utils/async-lock.ts
Normal file
36
packages/common/infra/src/utils/async-lock.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
101
packages/common/infra/src/utils/async-queue.ts
Normal file
101
packages/common/infra/src/utils/async-queue.ts
Normal 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;
|
||||
}
|
||||
41
packages/common/infra/src/utils/exhaustmap-with-trailing.ts
Normal file
41
packages/common/infra/src/utils/exhaustmap-with-trailing.ts
Normal 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();
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
74
packages/common/infra/src/utils/fractional-indexing.ts
Normal file
74
packages/common/infra/src/utils/fractional-indexing.ts
Normal 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');
|
||||
}
|
||||
10
packages/common/infra/src/utils/index.ts
Normal file
10
packages/common/infra/src/utils/index.ts
Normal 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';
|
||||
17
packages/common/infra/src/utils/merge-updates.ts
Normal file
17
packages/common/infra/src/utils/merge-updates.ts
Normal 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);
|
||||
}
|
||||
99
packages/common/infra/src/utils/object-pool.ts
Normal file
99
packages/common/infra/src/utils/object-pool.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
50
packages/common/infra/src/utils/redirect-allowlist.ts
Normal file
50
packages/common/infra/src/utils/redirect-allowlist.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
34
packages/common/infra/src/utils/shallow-equal.ts
Normal file
34
packages/common/infra/src/utils/shallow-equal.ts
Normal 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;
|
||||
}
|
||||
59
packages/common/infra/src/utils/stable-hash.ts
Normal file
59
packages/common/infra/src/utils/stable-hash.ts
Normal 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;
|
||||
}
|
||||
9
packages/common/infra/src/utils/throw-if-aborted.ts
Normal file
9
packages/common/infra/src/utils/throw-if-aborted.ts
Normal 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';
|
||||
201
packages/common/infra/src/utils/yjs-observable.ts
Normal file
201
packages/common/infra/src/utils/yjs-observable.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user