mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
feat(infra): framework
This commit is contained in:
@@ -48,7 +48,6 @@ const allPackages = [
|
|||||||
'packages/frontend/i18n',
|
'packages/frontend/i18n',
|
||||||
'packages/frontend/native',
|
'packages/frontend/native',
|
||||||
'packages/frontend/templates',
|
'packages/frontend/templates',
|
||||||
'packages/frontend/workspace-impl',
|
|
||||||
'packages/common/debug',
|
'packages/common/debug',
|
||||||
'packages/common/env',
|
'packages/common/env',
|
||||||
'packages/common/infra',
|
'packages/common/infra',
|
||||||
|
|||||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -29,11 +29,6 @@ mod:plugin-cli:
|
|||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- 'tools/plugin-cli/**/*'
|
- 'tools/plugin-cli/**/*'
|
||||||
|
|
||||||
mod:workspace-impl:
|
|
||||||
- changed-files:
|
|
||||||
- any-glob-to-any-file:
|
|
||||||
- 'packages/frontend/workspace-impl/**/*'
|
|
||||||
|
|
||||||
mod:i18n:
|
mod:i18n:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ It includes the global constants, browser and system check.
|
|||||||
|
|
||||||
This package should be imported at the very beginning of the entry point.
|
This package should be imported at the very beginning of the entry point.
|
||||||
|
|
||||||
### `@affine/workspace-impl`
|
|
||||||
|
|
||||||
Current we have two workspace plugin:
|
|
||||||
|
|
||||||
- `local` for local workspace, which is the default workspace type.
|
|
||||||
- `affine` for cloud workspace, which is the workspace type for AFFiNE Cloud with OctoBase backend.
|
|
||||||
|
|
||||||
#### Design principles
|
#### Design principles
|
||||||
|
|
||||||
- Each workspace plugin has its state and is isolated from other workspace plugins.
|
- Each workspace plugin has its state and is isolated from other workspace plugins.
|
||||||
|
|||||||
@@ -78,12 +78,30 @@ export class PagePermissionResolver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => WorkspacePage, {
|
||||||
|
description: 'Get public page of a workspace by page id.',
|
||||||
|
complexity: 2,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
async publicPage(
|
||||||
|
@Parent() workspace: WorkspaceType,
|
||||||
|
@Args('pageId') pageId: string
|
||||||
|
) {
|
||||||
|
return this.prisma.workspacePage.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
pageId,
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
@Mutation(() => Boolean, {
|
@Mutation(() => Boolean, {
|
||||||
name: 'sharePage',
|
name: 'sharePage',
|
||||||
deprecationReason: 'renamed to publicPage',
|
deprecationReason: 'renamed to publishPage',
|
||||||
})
|
})
|
||||||
async deprecatedSharePage(
|
async deprecatedSharePage(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ type Mutation {
|
|||||||
sendVerifyEmail(callbackUrl: String!): Boolean!
|
sendVerifyEmail(callbackUrl: String!): Boolean!
|
||||||
setBlob(blob: Upload!, workspaceId: String!): String!
|
setBlob(blob: Upload!, workspaceId: String!): String!
|
||||||
setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean!
|
setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean!
|
||||||
sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publicPage")
|
sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publishPage")
|
||||||
signIn(email: String!, password: String!): UserType!
|
signIn(email: String!, password: String!): UserType!
|
||||||
signUp(email: String!, name: String!, password: String!): UserType!
|
signUp(email: String!, name: String!, password: String!): UserType!
|
||||||
updateProfile(input: UpdateUserInput!): UserType!
|
updateProfile(input: UpdateUserInput!): UserType!
|
||||||
@@ -530,6 +530,9 @@ type WorkspaceType {
|
|||||||
"""is Public workspace"""
|
"""is Public workspace"""
|
||||||
public: Boolean!
|
public: Boolean!
|
||||||
|
|
||||||
|
"""Get public page of a workspace by page id."""
|
||||||
|
publicPage(pageId: String!): WorkspacePage
|
||||||
|
|
||||||
"""Public pages of a workspace"""
|
"""Public pages of a workspace"""
|
||||||
publicPages: [WorkspacePage!]!
|
publicPages: [WorkspacePage!]!
|
||||||
|
|
||||||
|
|||||||
1
packages/common/env/src/global.ts
vendored
1
packages/common/env/src/global.ts
vendored
@@ -18,7 +18,6 @@ export const runtimeFlagsSchema = z.object({
|
|||||||
enablePreloading: z.boolean(),
|
enablePreloading: z.boolean(),
|
||||||
enableNewSettingModal: z.boolean(),
|
enableNewSettingModal: z.boolean(),
|
||||||
enableNewSettingUnstableApi: z.boolean(),
|
enableNewSettingUnstableApi: z.boolean(),
|
||||||
enableSQLiteProvider: z.boolean(),
|
|
||||||
enableCloud: z.boolean(),
|
enableCloud: z.boolean(),
|
||||||
enableCaptcha: z.boolean(),
|
enableCaptcha: z.boolean(),
|
||||||
enableEnhanceShareMode: z.boolean(),
|
enableEnhanceShareMode: z.boolean(),
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CircularDependencyError,
|
|
||||||
createIdentifier,
|
|
||||||
createScope,
|
|
||||||
DuplicateServiceDefinitionError,
|
|
||||||
MissingDependencyError,
|
|
||||||
RecursionLimitError,
|
|
||||||
ServiceCollection,
|
|
||||||
ServiceNotFoundError,
|
|
||||||
ServiceProvider,
|
|
||||||
} from '../';
|
|
||||||
|
|
||||||
describe('di', () => {
|
|
||||||
test('basic', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
class TestService {
|
|
||||||
a = 'b';
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.add(TestService);
|
|
||||||
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
expect(provider.get(TestService)).toEqual({ a: 'b' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('size', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
class TestService {
|
|
||||||
a = 'b';
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.add(TestService);
|
|
||||||
|
|
||||||
expect(serviceCollection.size).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dependency', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
|
|
||||||
class A {
|
|
||||||
value = 'hello world';
|
|
||||||
}
|
|
||||||
|
|
||||||
class B {
|
|
||||||
constructor(public a: A) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class C {
|
|
||||||
constructor(public b: B) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.add(A).add(B, [A]).add(C, [B]);
|
|
||||||
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
|
|
||||||
expect(provider.get(C).b.a.value).toEqual('hello world');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('identifier', () => {
|
|
||||||
interface Animal {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
const Animal = createIdentifier<Animal>('Animal');
|
|
||||||
|
|
||||||
class Cat {
|
|
||||||
constructor() {}
|
|
||||||
name = 'cat';
|
|
||||||
}
|
|
||||||
|
|
||||||
class Zoo {
|
|
||||||
constructor(public animal: Animal) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
serviceCollection.addImpl(Animal, Cat).add(Zoo, [Animal]);
|
|
||||||
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
expect(provider.get(Zoo).animal.name).toEqual('cat');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('variant', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
|
|
||||||
interface USB {
|
|
||||||
speed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const USB = createIdentifier<USB>('USB');
|
|
||||||
|
|
||||||
class TypeA implements USB {
|
|
||||||
speed = 100;
|
|
||||||
}
|
|
||||||
class TypeC implements USB {
|
|
||||||
speed = 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PC {
|
|
||||||
constructor(
|
|
||||||
public typeA: USB,
|
|
||||||
public ports: USB[]
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection
|
|
||||||
.addImpl(USB('A'), TypeA)
|
|
||||||
.addImpl(USB('C'), TypeC)
|
|
||||||
.add(PC, [USB('A'), [USB]]);
|
|
||||||
|
|
||||||
const provider = serviceCollection.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 serviceCollection = new ServiceCollection();
|
|
||||||
interface Command {
|
|
||||||
shortcut: string;
|
|
||||||
callback: () => void;
|
|
||||||
}
|
|
||||||
const Command = createIdentifier<Command>('command');
|
|
||||||
|
|
||||||
let pageSystemInitialized = false;
|
|
||||||
|
|
||||||
class PageSystem {
|
|
||||||
mode = 'page';
|
|
||||||
name = 'helloworld';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
pageSystemInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
switchToEdgeless() {
|
|
||||||
this.mode = 'edgeless';
|
|
||||||
}
|
|
||||||
|
|
||||||
rename() {
|
|
||||||
this.name = 'foobar';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CommandSystem {
|
|
||||||
constructor(public commands: Command[]) {}
|
|
||||||
|
|
||||||
execute(shortcut: string) {
|
|
||||||
const command = this.commands.find(c => c.shortcut === shortcut);
|
|
||||||
if (command) {
|
|
||||||
command.callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.add(PageSystem);
|
|
||||||
serviceCollection.add(CommandSystem, [[Command]]);
|
|
||||||
serviceCollection.addImpl(Command('switch'), p => ({
|
|
||||||
shortcut: 'option+s',
|
|
||||||
callback: () => p.get(PageSystem).switchToEdgeless(),
|
|
||||||
}));
|
|
||||||
serviceCollection.addImpl(Command('rename'), p => ({
|
|
||||||
shortcut: 'f2',
|
|
||||||
callback: () => p.get(PageSystem).rename(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const provider = serviceCollection.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 serviceCollection = new ServiceCollection();
|
|
||||||
|
|
||||||
const something = createIdentifier<any>('USB');
|
|
||||||
|
|
||||||
class A {
|
|
||||||
a = 'i am A';
|
|
||||||
}
|
|
||||||
|
|
||||||
class B {
|
|
||||||
b = 'i am B';
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.addImpl(something, A).override(something, B);
|
|
||||||
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
expect(provider.get(something)).toEqual({ b: 'i am B' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scope', () => {
|
|
||||||
const services = new ServiceCollection();
|
|
||||||
|
|
||||||
const workspaceScope = createScope('workspace');
|
|
||||||
const pageScope = createScope('page', workspaceScope);
|
|
||||||
const editorScope = createScope('editor', pageScope);
|
|
||||||
|
|
||||||
class System {
|
|
||||||
appName = 'affine';
|
|
||||||
}
|
|
||||||
|
|
||||||
services.add(System);
|
|
||||||
|
|
||||||
class Workspace {
|
|
||||||
name = 'workspace';
|
|
||||||
constructor(public system: System) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
services.scope(workspaceScope).add(Workspace, [System]);
|
|
||||||
class Page {
|
|
||||||
name = 'page';
|
|
||||||
constructor(
|
|
||||||
public system: System,
|
|
||||||
public workspace: Workspace
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
services.scope(pageScope).add(Page, [System, Workspace]);
|
|
||||||
|
|
||||||
class Editor {
|
|
||||||
name = 'editor';
|
|
||||||
constructor(public page: Page) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
services.scope(editorScope).add(Editor, [Page]);
|
|
||||||
|
|
||||||
const root = services.provider();
|
|
||||||
expect(root.get(System).appName).toEqual('affine');
|
|
||||||
expect(() => root.get(Workspace)).toThrowError(ServiceNotFoundError);
|
|
||||||
|
|
||||||
const workspace = services.provider(workspaceScope, root);
|
|
||||||
expect(workspace.get(Workspace).name).toEqual('workspace');
|
|
||||||
expect(workspace.get(System).appName).toEqual('affine');
|
|
||||||
expect(() => root.get(Page)).toThrowError(ServiceNotFoundError);
|
|
||||||
|
|
||||||
const page = services.provider(pageScope, workspace);
|
|
||||||
expect(page.get(Page).name).toEqual('page');
|
|
||||||
expect(page.get(Workspace).name).toEqual('workspace');
|
|
||||||
expect(page.get(System).appName).toEqual('affine');
|
|
||||||
|
|
||||||
const editor = services.provider(editorScope, page);
|
|
||||||
expect(editor.get(Editor).name).toEqual('editor');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('service not found', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
expect(() => provider.get(createIdentifier('SomeService'))).toThrowError(
|
|
||||||
ServiceNotFoundError
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('missing dependency', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
|
|
||||||
class A {
|
|
||||||
value = 'hello world';
|
|
||||||
}
|
|
||||||
|
|
||||||
class B {
|
|
||||||
constructor(public a: A) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.add(B, [A]);
|
|
||||||
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
expect(() => provider.get(B)).toThrowError(MissingDependencyError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('circular dependency', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
|
|
||||||
class A {
|
|
||||||
constructor(public c: C) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class B {
|
|
||||||
constructor(public a: A) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class C {
|
|
||||||
constructor(public b: B) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.add(A, [C]).add(B, [A]).add(C, [B]);
|
|
||||||
|
|
||||||
const provider = serviceCollection.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 ServiceCollection();
|
|
||||||
|
|
||||||
class A {}
|
|
||||||
|
|
||||||
serviceCollection.add(A);
|
|
||||||
expect(() => serviceCollection.add(A)).toThrowError(
|
|
||||||
DuplicateServiceDefinitionError
|
|
||||||
);
|
|
||||||
|
|
||||||
class B {}
|
|
||||||
const Something = createIdentifier('something');
|
|
||||||
serviceCollection.addImpl(Something, A);
|
|
||||||
expect(() => serviceCollection.addImpl(Something, B)).toThrowError(
|
|
||||||
DuplicateServiceDefinitionError
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recursion limit', () => {
|
|
||||||
// maxmium resolve depth is 100
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
const Something = createIdentifier('something');
|
|
||||||
let i = 0;
|
|
||||||
for (; i < 100; i++) {
|
|
||||||
const next = i + 1;
|
|
||||||
|
|
||||||
class Test {
|
|
||||||
constructor(_next: any) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceCollection.addImpl(Something(i.toString()), Test, [
|
|
||||||
Something(next.toString()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
class Final {
|
|
||||||
a = 'b';
|
|
||||||
}
|
|
||||||
serviceCollection.addImpl(Something(i.toString()), Final);
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
expect(() => provider.get(Something('0'))).toThrowError(
|
|
||||||
RecursionLimitError
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('self resolve', () => {
|
|
||||||
const serviceCollection = new ServiceCollection();
|
|
||||||
const provider = serviceCollection.provider();
|
|
||||||
expect(provider.get(ServiceProvider)).toEqual(provider);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
import { DEFAULT_SERVICE_VARIANT, ROOT_SCOPE } from './consts';
|
|
||||||
import { DuplicateServiceDefinitionError } from './error';
|
|
||||||
import { parseIdentifier } from './identifier';
|
|
||||||
import type { ServiceProvider } from './provider';
|
|
||||||
import { BasicServiceProvider } from './provider';
|
|
||||||
import { stringifyScope } from './scope';
|
|
||||||
import type {
|
|
||||||
GeneralServiceIdentifier,
|
|
||||||
ServiceFactory,
|
|
||||||
ServiceIdentifier,
|
|
||||||
ServiceIdentifierType,
|
|
||||||
ServiceIdentifierValue,
|
|
||||||
ServiceScope,
|
|
||||||
ServiceVariant,
|
|
||||||
Type,
|
|
||||||
TypesToDeps,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A collection of services.
|
|
||||||
*
|
|
||||||
* ServiceCollection basically is a tuple of `[scope, identifier, variant, factory]` with some helper methods.
|
|
||||||
* It just stores the definitions of services. It never holds any instances of services.
|
|
||||||
*
|
|
||||||
* # Usage
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const services = new ServiceCollection();
|
|
||||||
* class ServiceA {
|
|
||||||
* // ...
|
|
||||||
* }
|
|
||||||
* // add a service
|
|
||||||
* services.add(ServiceA);
|
|
||||||
*
|
|
||||||
* class ServiceB {
|
|
||||||
* constructor(serviceA: ServiceA) {}
|
|
||||||
* }
|
|
||||||
* // add a service with dependency
|
|
||||||
* services.add(ServiceB, [ServiceA]);
|
|
||||||
* ^ dependency class/identifier, match ServiceB's constructor
|
|
||||||
*
|
|
||||||
* const FeatureA = createIdentifier<FeatureA>('Config');
|
|
||||||
*
|
|
||||||
* // add a implementation for a service identifier
|
|
||||||
* services.addImpl(FeatureA, ServiceA);
|
|
||||||
*
|
|
||||||
* // override a service
|
|
||||||
* services.override(ServiceA, NewServiceA);
|
|
||||||
*
|
|
||||||
* // create a service provider
|
|
||||||
* const provider = services.provider();
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* # The data structure
|
|
||||||
*
|
|
||||||
* The data structure of ServiceCollection is a three-layer nested Map, used to represent the tuple of
|
|
||||||
* `[scope, identifier, variant, factory]`.
|
|
||||||
* Such a data structure ensures that a service factory can be uniquely determined by `[scope, identifier, variant]`.
|
|
||||||
*
|
|
||||||
* When a service added:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* services.add(ServiceClass)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* The data structure will be:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* Map {
|
|
||||||
* '': Map { // scope
|
|
||||||
* 'ServiceClass': Map { // identifier
|
|
||||||
* 'default': // variant
|
|
||||||
* () => new ServiceClass() // factory
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* # Dependency relationship
|
|
||||||
*
|
|
||||||
* The dependency relationships of services are not actually stored in the ServiceCollection,
|
|
||||||
* but are transformed into a factory function when the service is added.
|
|
||||||
*
|
|
||||||
* For example:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* services.add(ServiceB, [ServiceA]);
|
|
||||||
*
|
|
||||||
* // is equivalent to
|
|
||||||
* services.addFactory(ServiceB, (provider) => new ServiceB(provider.get(ServiceA)));
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* For multiple implementations of the same service identifier, can be defined as:
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* services.add(ServiceB, [[FeatureA]]);
|
|
||||||
*
|
|
||||||
* // is equivalent to
|
|
||||||
* services.addFactory(ServiceB, (provider) => new ServiceB(provider.getAll(FeatureA)));
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class ServiceCollection {
|
|
||||||
private readonly services: Map<
|
|
||||||
string,
|
|
||||||
Map<string, Map<ServiceVariant, ServiceFactory>>
|
|
||||||
> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an empty service collection.
|
|
||||||
*
|
|
||||||
* same as `new ServiceCollection()`
|
|
||||||
*/
|
|
||||||
static get EMPTY() {
|
|
||||||
return new ServiceCollection();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of services in the collection.
|
|
||||||
*/
|
|
||||||
get size() {
|
|
||||||
let size = 0;
|
|
||||||
for (const [, identifiers] of this.services) {
|
|
||||||
for (const [, variants] of identifiers) {
|
|
||||||
size += variants.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link ServiceCollectionEditor.add}
|
|
||||||
*/
|
|
||||||
get add() {
|
|
||||||
return new ServiceCollectionEditor(this).add;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link ServiceCollectionEditor.addImpl}
|
|
||||||
*/
|
|
||||||
get addImpl() {
|
|
||||||
return new ServiceCollectionEditor(this).addImpl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link ServiceCollectionEditor.scope}
|
|
||||||
*/
|
|
||||||
get scope() {
|
|
||||||
return new ServiceCollectionEditor(this).scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @see {@link ServiceCollectionEditor.scope}
|
|
||||||
*/
|
|
||||||
get override() {
|
|
||||||
return new ServiceCollectionEditor(this).override;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal Use {@link addImpl} instead.
|
|
||||||
*/
|
|
||||||
addValue<T>(
|
|
||||||
identifier: GeneralServiceIdentifier<T>,
|
|
||||||
value: T,
|
|
||||||
{ scope, override }: { scope?: ServiceScope; override?: boolean } = {}
|
|
||||||
) {
|
|
||||||
this.addFactory(
|
|
||||||
parseIdentifier(identifier) as ServiceIdentifier<T>,
|
|
||||||
() => value,
|
|
||||||
{
|
|
||||||
scope,
|
|
||||||
override,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal Use {@link addImpl} instead.
|
|
||||||
*/
|
|
||||||
addFactory<T>(
|
|
||||||
identifier: GeneralServiceIdentifier<T>,
|
|
||||||
factory: ServiceFactory<T>,
|
|
||||||
{ scope, override }: { scope?: ServiceScope; override?: boolean } = {}
|
|
||||||
) {
|
|
||||||
// convert scope to string
|
|
||||||
const normalizedScope = stringifyScope(scope ?? ROOT_SCOPE);
|
|
||||||
const normalizedIdentifier = parseIdentifier(identifier);
|
|
||||||
const normalizedVariant =
|
|
||||||
normalizedIdentifier.variant ?? DEFAULT_SERVICE_VARIANT;
|
|
||||||
|
|
||||||
const services =
|
|
||||||
this.services.get(normalizedScope) ??
|
|
||||||
new Map<string, Map<ServiceVariant, ServiceFactory>>();
|
|
||||||
|
|
||||||
const variants =
|
|
||||||
services.get(normalizedIdentifier.identifierName) ??
|
|
||||||
new Map<ServiceVariant, ServiceFactory>();
|
|
||||||
|
|
||||||
// throw if service already exists, unless it is an override
|
|
||||||
if (variants.has(normalizedVariant) && !override) {
|
|
||||||
throw new DuplicateServiceDefinitionError(normalizedIdentifier);
|
|
||||||
}
|
|
||||||
variants.set(normalizedVariant, factory);
|
|
||||||
services.set(normalizedIdentifier.identifierName, variants);
|
|
||||||
this.services.set(normalizedScope, services);
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(identifier: ServiceIdentifierValue, scope: ServiceScope = ROOT_SCOPE) {
|
|
||||||
const normalizedScope = stringifyScope(scope);
|
|
||||||
const normalizedIdentifier = parseIdentifier(identifier);
|
|
||||||
const normalizedVariant =
|
|
||||||
normalizedIdentifier.variant ?? DEFAULT_SERVICE_VARIANT;
|
|
||||||
|
|
||||||
const services = this.services.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: ServiceScope = ROOT_SCOPE,
|
|
||||||
parent: ServiceProvider | null = null
|
|
||||||
): ServiceProvider {
|
|
||||||
return new BasicServiceProvider(this, scope, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
getFactory(
|
|
||||||
identifier: ServiceIdentifierValue,
|
|
||||||
scope: ServiceScope = ROOT_SCOPE
|
|
||||||
): ServiceFactory | undefined {
|
|
||||||
return this.services
|
|
||||||
.get(stringifyScope(scope))
|
|
||||||
?.get(identifier.identifierName)
|
|
||||||
?.get(identifier.variant ?? DEFAULT_SERVICE_VARIANT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
getFactoryAll(
|
|
||||||
identifier: ServiceIdentifierValue,
|
|
||||||
scope: ServiceScope = ROOT_SCOPE
|
|
||||||
): Map<ServiceVariant, ServiceFactory> {
|
|
||||||
return new Map(
|
|
||||||
this.services.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(): ServiceCollection {
|
|
||||||
const di = new ServiceCollection();
|
|
||||||
for (const [scope, identifiers] of this.services) {
|
|
||||||
const s = new Map();
|
|
||||||
for (const [identifier, variants] of identifiers) {
|
|
||||||
s.set(identifier, new Map(variants));
|
|
||||||
}
|
|
||||||
di.services.set(scope, s);
|
|
||||||
}
|
|
||||||
return di;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper class to edit a service collection.
|
|
||||||
*/
|
|
||||||
class ServiceCollectionEditor {
|
|
||||||
private currentScope: ServiceScope = ROOT_SCOPE;
|
|
||||||
|
|
||||||
constructor(private readonly collection: ServiceCollection) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a service to the collection.
|
|
||||||
*
|
|
||||||
* @see {@link ServiceCollection}
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* add(ServiceClass, [dependencies, ...])
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
add = <
|
|
||||||
T extends new (...args: any) => any,
|
|
||||||
const Deps extends TypesToDeps<ConstructorParameters<T>> = TypesToDeps<
|
|
||||||
ConstructorParameters<T>
|
|
||||||
>,
|
|
||||||
>(
|
|
||||||
cls: T,
|
|
||||||
...[deps]: Deps extends [] ? [] : [Deps]
|
|
||||||
): this => {
|
|
||||||
this.collection.addFactory<any>(
|
|
||||||
cls as any,
|
|
||||||
dependenciesToFactory(cls, deps as any),
|
|
||||||
{ scope: this.currentScope }
|
|
||||||
);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an implementation for identifier to the collection.
|
|
||||||
*
|
|
||||||
* @see {@link ServiceCollection}
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* addImpl(ServiceIdentifier, ServiceClass, [dependencies, ...])
|
|
||||||
* or
|
|
||||||
* addImpl(ServiceIdentifier, Instance)
|
|
||||||
* or
|
|
||||||
* addImpl(ServiceIdentifier, Factory)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
addImpl = <
|
|
||||||
Arg1 extends ServiceIdentifier<any> | (new (...args: any) => any),
|
|
||||||
Arg2 extends Type<Trait> | ServiceFactory<Trait> | Trait,
|
|
||||||
Trait = ServiceIdentifierType<Arg1>,
|
|
||||||
Deps extends Arg2 extends Type<Trait>
|
|
||||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
|
||||||
: [] = Arg2 extends Type<Trait>
|
|
||||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
|
||||||
: [],
|
|
||||||
Arg3 extends Deps = Deps,
|
|
||||||
>(
|
|
||||||
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.currentScope }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.collection.addValue(identifier, arg2 as any, {
|
|
||||||
scope: this.currentScope,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* same as {@link addImpl} but this method will override the service if it exists.
|
|
||||||
*
|
|
||||||
* @see {@link ServiceCollection}
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* override(OriginServiceClass, NewServiceClass, [dependencies, ...])
|
|
||||||
* or
|
|
||||||
* override(ServiceIdentifier, ServiceClass, [dependencies, ...])
|
|
||||||
* or
|
|
||||||
* override(ServiceIdentifier, Instance)
|
|
||||||
* or
|
|
||||||
* override(ServiceIdentifier, Factory)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
override = <
|
|
||||||
Arg1 extends ServiceIdentifier<any>,
|
|
||||||
Arg2 extends Type<Trait> | ServiceFactory<Trait> | Trait | null,
|
|
||||||
Trait = ServiceIdentifierType<Arg1>,
|
|
||||||
Deps extends Arg2 extends Type<Trait>
|
|
||||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
|
||||||
: [] = Arg2 extends Type<Trait>
|
|
||||||
? TypesToDeps<ConstructorParameters<Arg2>>
|
|
||||||
: [],
|
|
||||||
Arg3 extends Deps = Deps,
|
|
||||||
>(
|
|
||||||
identifier: Arg1,
|
|
||||||
arg2: Arg2,
|
|
||||||
...[arg3]: Arg3 extends [] ? [] : [Arg3]
|
|
||||||
): this => {
|
|
||||||
if (arg2 === null) {
|
|
||||||
this.collection.remove(identifier, this.currentScope);
|
|
||||||
return this;
|
|
||||||
} else if (arg2 instanceof Function) {
|
|
||||||
this.collection.addFactory<any>(
|
|
||||||
identifier,
|
|
||||||
dependenciesToFactory(arg2, arg3 as any[]),
|
|
||||||
{ scope: this.currentScope, override: true }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.collection.addValue(identifier, arg2 as any, {
|
|
||||||
scope: this.currentScope,
|
|
||||||
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: ServiceScope): ServiceCollectionEditor => {
|
|
||||||
this.currentScope = scope;
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert dependencies definition to a factory function.
|
|
||||||
*/
|
|
||||||
function dependenciesToFactory(
|
|
||||||
cls: any,
|
|
||||||
deps: any[] = []
|
|
||||||
): ServiceFactory<any> {
|
|
||||||
return (provider: ServiceProvider) => {
|
|
||||||
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 (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import type { ServiceVariant } from './types';
|
|
||||||
|
|
||||||
export const DEFAULT_SERVICE_VARIANT: ServiceVariant = 'default';
|
|
||||||
export const ROOT_SCOPE = [];
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { DEFAULT_SERVICE_VARIANT } from './consts';
|
|
||||||
import type { ServiceIdentifierValue } from './types';
|
|
||||||
|
|
||||||
export class RecursionLimitError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super('Dynamic resolve recursion limit reached');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CircularDependencyError extends Error {
|
|
||||||
constructor(public readonly dependencyStack: ServiceIdentifierValue[]) {
|
|
||||||
super(
|
|
||||||
`A circular dependency was detected.\n` +
|
|
||||||
stringifyDependencyStack(dependencyStack)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ServiceNotFoundError extends Error {
|
|
||||||
constructor(public readonly identifier: ServiceIdentifierValue) {
|
|
||||||
super(`Service ${stringifyIdentifier(identifier)} not found in container`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MissingDependencyError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly from: ServiceIdentifierValue,
|
|
||||||
public readonly target: ServiceIdentifierValue,
|
|
||||||
public readonly dependencyStack: ServiceIdentifierValue[]
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
`Missing dependency ${stringifyIdentifier(
|
|
||||||
target
|
|
||||||
)} in creating service ${stringifyIdentifier(
|
|
||||||
from
|
|
||||||
)}.\n${stringifyDependencyStack(dependencyStack)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DuplicateServiceDefinitionError extends Error {
|
|
||||||
constructor(public readonly identifier: ServiceIdentifierValue) {
|
|
||||||
super(`Service ${stringifyIdentifier(identifier)} already exists`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyIdentifier(identifier: ServiceIdentifierValue) {
|
|
||||||
return `[${identifier.identifierName}]${
|
|
||||||
identifier.variant !== DEFAULT_SERVICE_VARIANT
|
|
||||||
? `(${identifier.variant})`
|
|
||||||
: ''
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyDependencyStack(dependencyStack: ServiceIdentifierValue[]) {
|
|
||||||
return dependencyStack
|
|
||||||
.map(identifier => `${stringifyIdentifier(identifier)}`)
|
|
||||||
.join(' -> ');
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export * from './collection';
|
|
||||||
export * from './consts';
|
|
||||||
export * from './error';
|
|
||||||
export * from './identifier';
|
|
||||||
export * from './provider';
|
|
||||||
export * from './scope';
|
|
||||||
export * from './types';
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import type { ServiceCollection } from './collection';
|
|
||||||
import {
|
|
||||||
CircularDependencyError,
|
|
||||||
MissingDependencyError,
|
|
||||||
RecursionLimitError,
|
|
||||||
ServiceNotFoundError,
|
|
||||||
} from './error';
|
|
||||||
import { parseIdentifier } from './identifier';
|
|
||||||
import type {
|
|
||||||
GeneralServiceIdentifier,
|
|
||||||
ServiceIdentifierValue,
|
|
||||||
ServiceVariant,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export interface ResolveOptions {
|
|
||||||
sameScope?: boolean;
|
|
||||||
optional?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class ServiceProvider {
|
|
||||||
abstract collection: ServiceCollection;
|
|
||||||
abstract getRaw(
|
|
||||||
identifier: ServiceIdentifierValue,
|
|
||||||
options?: ResolveOptions
|
|
||||||
): any;
|
|
||||||
abstract getAllRaw(
|
|
||||||
identifier: ServiceIdentifierValue,
|
|
||||||
options?: ResolveOptions
|
|
||||||
): Map<ServiceVariant, any>;
|
|
||||||
|
|
||||||
get<T>(identifier: GeneralServiceIdentifier<T>, options?: ResolveOptions): T {
|
|
||||||
return this.getRaw(parseIdentifier(identifier), {
|
|
||||||
...options,
|
|
||||||
optional: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll<T>(
|
|
||||||
identifier: GeneralServiceIdentifier<T>,
|
|
||||||
options?: ResolveOptions
|
|
||||||
): Map<ServiceVariant, T> {
|
|
||||||
return this.getAllRaw(parseIdentifier(identifier), {
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getOptional<T>(
|
|
||||||
identifier: GeneralServiceIdentifier<T>,
|
|
||||||
options?: ResolveOptions
|
|
||||||
): T | null {
|
|
||||||
return this.getRaw(parseIdentifier(identifier), {
|
|
||||||
...options,
|
|
||||||
optional: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ServiceCachePool {
|
|
||||||
cache: Map<string, Map<ServiceVariant, any>> = new Map();
|
|
||||||
|
|
||||||
getOrInsert(identifier: ServiceIdentifierValue, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ServiceResolver extends ServiceProvider {
|
|
||||||
constructor(
|
|
||||||
public readonly provider: BasicServiceProvider,
|
|
||||||
public readonly depth = 0,
|
|
||||||
public readonly stack: ServiceIdentifierValue[] = []
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
collection = this.provider.collection;
|
|
||||||
|
|
||||||
getRaw(
|
|
||||||
identifier: ServiceIdentifierValue,
|
|
||||||
{ sameScope = false, optional = false }: 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,
|
|
||||||
optional,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (optional) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
throw new ServiceNotFoundError(identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.provider.cache.getOrInsert(identifier, () => {
|
|
||||||
const nextResolver = this.track(identifier);
|
|
||||||
try {
|
|
||||||
return factory(nextResolver);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ServiceNotFoundError) {
|
|
||||||
throw new MissingDependencyError(
|
|
||||||
identifier,
|
|
||||||
err.identifier,
|
|
||||||
this.stack
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllRaw(
|
|
||||||
identifier: ServiceIdentifierValue,
|
|
||||||
{ sameScope = false }: ResolveOptions = {}
|
|
||||||
): Map<ServiceVariant, 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<ServiceVariant, any>();
|
|
||||||
|
|
||||||
for (const [variant, factory] of vars) {
|
|
||||||
const service = this.provider.cache.getOrInsert(
|
|
||||||
{ identifierName: identifier.identifierName, variant },
|
|
||||||
() => {
|
|
||||||
const nextResolver = this.track(identifier);
|
|
||||||
try {
|
|
||||||
return factory(nextResolver);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ServiceNotFoundError) {
|
|
||||||
throw new MissingDependencyError(
|
|
||||||
identifier,
|
|
||||||
err.identifier,
|
|
||||||
this.stack
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
result.set(variant, service);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(identifier: ServiceIdentifierValue): ServiceResolver {
|
|
||||||
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 ServiceResolver(this.provider, depth, [
|
|
||||||
...this.stack,
|
|
||||||
identifier,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BasicServiceProvider extends ServiceProvider {
|
|
||||||
public readonly cache = new ServiceCachePool();
|
|
||||||
public readonly collection: ServiceCollection;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
collection: ServiceCollection,
|
|
||||||
public readonly scope: string[],
|
|
||||||
public readonly parent: ServiceProvider | null
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
this.collection = collection.clone();
|
|
||||||
this.collection.addValue(ServiceProvider, this, {
|
|
||||||
scope: scope,
|
|
||||||
override: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getRaw(identifier: ServiceIdentifierValue, options?: ResolveOptions) {
|
|
||||||
const resolver = new ServiceResolver(this);
|
|
||||||
return resolver.getRaw(identifier, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllRaw(
|
|
||||||
identifier: ServiceIdentifierValue,
|
|
||||||
options?: ResolveOptions
|
|
||||||
): Map<ServiceVariant, any> {
|
|
||||||
const resolver = new ServiceResolver(this);
|
|
||||||
return resolver.getAllRaw(identifier, options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { ROOT_SCOPE } from './consts';
|
|
||||||
import type { ServiceScope } from './types';
|
|
||||||
|
|
||||||
export function createScope(
|
|
||||||
name: string,
|
|
||||||
base: ServiceScope = ROOT_SCOPE
|
|
||||||
): ServiceScope {
|
|
||||||
return [...base, name];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stringifyScope(scope: ServiceScope): string {
|
|
||||||
return scope.join('/');
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { ServiceProvider } from './provider';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
||||||
export type Type<T = any> = abstract new (...args: any) => T;
|
|
||||||
|
|
||||||
export type ServiceFactory<T = any> = (provider: ServiceProvider) => T;
|
|
||||||
export type ServiceVariant = string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type ServiceScope = string[];
|
|
||||||
|
|
||||||
export type ServiceIdentifierValue = {
|
|
||||||
identifierName: string;
|
|
||||||
variant: ServiceVariant;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GeneralServiceIdentifier<T = any> = ServiceIdentifier<T> | Type<T>;
|
|
||||||
|
|
||||||
export type ServiceIdentifier<T> = {
|
|
||||||
identifierName: string;
|
|
||||||
variant: ServiceVariant;
|
|
||||||
__TYPE__: T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceIdentifierType<T> =
|
|
||||||
T extends ServiceIdentifier<infer R>
|
|
||||||
? R
|
|
||||||
: T extends Type<infer R>
|
|
||||||
? R
|
|
||||||
: never;
|
|
||||||
|
|
||||||
export type TypesToDeps<T extends any[]> = {
|
|
||||||
[index in keyof T]:
|
|
||||||
| GeneralServiceIdentifier<T[index]>
|
|
||||||
| (T[index] extends (infer I)[] ? [GeneralServiceIdentifier<I>] : never);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React, { useContext } from 'react';
|
|
||||||
|
|
||||||
import type { GeneralServiceIdentifier, ServiceProvider } from '../core';
|
|
||||||
import { ServiceCollection } from '../core';
|
|
||||||
|
|
||||||
export const ServiceProviderContext = React.createContext(
|
|
||||||
ServiceCollection.EMPTY.provider()
|
|
||||||
);
|
|
||||||
|
|
||||||
export function useService<T>(
|
|
||||||
identifier: GeneralServiceIdentifier<T>,
|
|
||||||
{ provider }: { provider?: ServiceProvider } = {}
|
|
||||||
): T {
|
|
||||||
const contextServiceProvider = useContext(ServiceProviderContext);
|
|
||||||
|
|
||||||
const serviceProvider = provider ?? contextServiceProvider;
|
|
||||||
|
|
||||||
return serviceProvider.get(identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useServiceOptional<T>(
|
|
||||||
identifier: GeneralServiceIdentifier<T>,
|
|
||||||
{ provider }: { provider?: ServiceProvider } = {}
|
|
||||||
): T | null {
|
|
||||||
const contextServiceProvider = useContext(ServiceProviderContext);
|
|
||||||
|
|
||||||
const serviceProvider = provider ?? contextServiceProvider;
|
|
||||||
|
|
||||||
return serviceProvider.getOptional(identifier);
|
|
||||||
}
|
|
||||||
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,27 @@
|
|||||||
|
import { CONSTRUCTOR_CONTEXT } from '../constructor-context';
|
||||||
|
import type { FrameworkProvider } from '../provider';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
export class Component<Props = {}> {
|
||||||
|
readonly framework: FrameworkProvider;
|
||||||
|
readonly props: Props;
|
||||||
|
|
||||||
|
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() {}
|
||||||
|
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Component } from './component';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
export class Entity<Props = {}> extends Component<Props> {
|
||||||
|
readonly __isEntity = true;
|
||||||
|
}
|
||||||
43
packages/common/infra/src/framework/core/components/scope.ts
Normal file
43
packages/common/infra/src/framework/core/components/scope.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Component } from './component';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
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(' -> ');
|
||||||
|
}
|
||||||
111
packages/common/infra/src/framework/core/event.ts
Normal file
111
packages/common/infra/src/framework/core/event.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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.id, handler.handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on<T>(id: string, listener: (event: FrameworkEvent<T>) => void) {
|
||||||
|
if (!this.listeners[id]) {
|
||||||
|
this.listeners[id] = [];
|
||||||
|
}
|
||||||
|
this.listeners[id].push(listener);
|
||||||
|
const off = this.parent?.on(id, listener);
|
||||||
|
return () => {
|
||||||
|
this.off(id, listener);
|
||||||
|
off?.();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
off<T>(id: string, listener: (event: FrameworkEvent<T>) => void) {
|
||||||
|
if (!this.listeners[id]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.listeners[id] = this.listeners[id].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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
527
packages/common/infra/src/framework/core/framework.ts
Normal file
527
packages/common/infra/src/framework/core/framework.ts
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
import type { Component } from './components/component';
|
||||||
|
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(OriginClass, NewClass, [dependencies, ...])
|
||||||
|
* or
|
||||||
|
* override(Identifier, Class, [dependencies, ...])
|
||||||
|
* or
|
||||||
|
* override(Identifier, Instance)
|
||||||
|
* or
|
||||||
|
* override(Identifier, Factory)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
override = <
|
||||||
|
Arg1 extends GeneralIdentifier<any>,
|
||||||
|
Arg2 extends Type<Trait> | ComponentFactory<Trait> | Trait | null,
|
||||||
|
Arg3 extends Deps,
|
||||||
|
Trait extends Component = 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 (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { stableHash } from '../../utils/stable-hash';
|
import { stableHash } from '../../utils/stable-hash';
|
||||||
import { DEFAULT_SERVICE_VARIANT } from './consts';
|
import type { Component } from './components/component';
|
||||||
|
import { DEFAULT_VARIANT } from './consts';
|
||||||
import type {
|
import type {
|
||||||
ServiceIdentifier,
|
ComponentVariant,
|
||||||
ServiceIdentifierValue,
|
Identifier,
|
||||||
ServiceVariant,
|
IdentifierValue,
|
||||||
Type,
|
Type,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* create a ServiceIdentifier.
|
* create a Identifier.
|
||||||
*
|
*
|
||||||
* ServiceIdentifier is used to identify a certain type of service. With the identifier, you can reference one or more services
|
* 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
|
* without knowing the specific implementation, thereby achieving
|
||||||
* [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control).
|
* [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control).
|
||||||
*
|
*
|
||||||
@@ -38,10 +39,10 @@ import type {
|
|||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* // register the implementation to the identifier
|
* // register the implementation to the identifier
|
||||||
* services.addImpl(Storage, LocalStorage);
|
* framework.impl(Storage, LocalStorage);
|
||||||
*
|
*
|
||||||
* // get the implementation from the identifier
|
* // get the implementation from the identifier
|
||||||
* const storage = services.provider().get(Storage);
|
* const storage = framework.provider().get(Storage);
|
||||||
* storage.set('foo', 'bar');
|
* storage.set('foo', 'bar');
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
@@ -63,13 +64,13 @@ import type {
|
|||||||
* const LocalStorage = Storage('local');
|
* const LocalStorage = Storage('local');
|
||||||
* const SessionStorage = Storage('session');
|
* const SessionStorage = Storage('session');
|
||||||
*
|
*
|
||||||
* services.addImpl(LocalStorage, LocalStorageImpl);
|
* framework.impl(LocalStorage, LocalStorageImpl);
|
||||||
* services.addImpl(SessionStorage, SessionStorageImpl);
|
* framework.impl(SessionStorage, SessionStorageImpl);
|
||||||
*
|
*
|
||||||
* // get the implementation from the identifier
|
* // get the implementation from the identifier
|
||||||
* const localStorage = services.provider().get(LocalStorage);
|
* const localStorage = framework.provider().get(LocalStorage);
|
||||||
* const sessionStorage = services.provider().get(SessionStorage);
|
* const sessionStorage = framework.provider().get(SessionStorage);
|
||||||
* const storage = services.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
|
* const storage = framework.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param name unique name of the identifier.
|
* @param name unique name of the identifier.
|
||||||
@@ -77,10 +78,10 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export function createIdentifier<T>(
|
export function createIdentifier<T>(
|
||||||
name: string,
|
name: string,
|
||||||
variant: ServiceVariant = DEFAULT_SERVICE_VARIANT
|
variant: ComponentVariant = DEFAULT_VARIANT
|
||||||
): ServiceIdentifier<T> & ((variant: ServiceVariant) => ServiceIdentifier<T>) {
|
): Identifier<T> & ((variant: ComponentVariant) => Identifier<T>) {
|
||||||
return Object.assign(
|
return Object.assign(
|
||||||
(variant: ServiceVariant) => {
|
(variant: ComponentVariant) => {
|
||||||
return createIdentifier<T>(name, variant);
|
return createIdentifier<T>(name, variant);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,15 +97,15 @@ export function createIdentifier<T>(
|
|||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export function createIdentifierFromConstructor<T>(
|
export function createIdentifierFromConstructor<T extends Component>(
|
||||||
target: Type<T>
|
target: Type<T>
|
||||||
): ServiceIdentifier<T> {
|
): Identifier<T> {
|
||||||
return createIdentifier<T>(`${target.name}${stableHash(target)}`);
|
return createIdentifier<T>(`${target.name}${stableHash(target)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseIdentifier(input: any): ServiceIdentifierValue {
|
export function parseIdentifier(input: any): IdentifierValue {
|
||||||
if (input.identifierName) {
|
if (input.identifierName) {
|
||||||
return input as ServiceIdentifierValue;
|
return input as IdentifierValue;
|
||||||
} else if (typeof input === 'function' && input.name) {
|
} else if (typeof input === 'function' && input.name) {
|
||||||
return createIdentifierFromConstructor(input);
|
return createIdentifierFromConstructor(input);
|
||||||
} else {
|
} else {
|
||||||
10
packages/common/infra/src/framework/core/index.ts
Normal file
10
packages/common/infra/src/framework/core/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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, OnEvent } from './event';
|
||||||
|
export { Framework } from './framework';
|
||||||
|
export { createIdentifier } from './identifier';
|
||||||
|
export type { FrameworkProvider, ResolveOptions } from './provider';
|
||||||
|
export type { GeneralIdentifier } from './types';
|
||||||
321
packages/common/infra/src/framework/core/provider.ts
Normal file
321
packages/common/infra/src/framework/core/provider.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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 | null => {
|
||||||
|
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) {
|
||||||
|
setImmediate(() => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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) {
|
||||||
|
// eslint-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();
|
||||||
|
}
|
||||||
|
}
|
||||||
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('/');
|
||||||
|
}
|
||||||
36
packages/common/infra/src/framework/core/types.ts
Normal file
36
packages/common/infra/src/framework/core/types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FrameworkProvider } from './provider';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
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;
|
||||||
|
};
|
||||||
126
packages/common/infra/src/framework/react/index.tsx
Normal file
126
packages/common/infra/src/framework/react/index.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useContext, useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { FrameworkProvider, Scope, Service } from '../core';
|
||||||
|
import { ComponentNotFoundError, Framework } from '../core';
|
||||||
|
import { parseIdentifier } from '../core/identifier';
|
||||||
|
import type { GeneralIdentifier, IdentifierType, Type } from '../core/types';
|
||||||
|
|
||||||
|
export const FrameworkStackContext = React.createContext<FrameworkProvider[]>([
|
||||||
|
Framework.EMPTY.provider(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function useService<T extends Service>(
|
||||||
|
identifier: GeneralIdentifier<T>
|
||||||
|
): T {
|
||||||
|
const stack = useContext(FrameworkStackContext);
|
||||||
|
|
||||||
|
let service: T | null = null;
|
||||||
|
|
||||||
|
for (let i = stack.length - 1; i >= 0; i--) {
|
||||||
|
service = stack[i].getOptional(identifier, {
|
||||||
|
sameScope: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
throw new ComponentNotFoundError(parseIdentifier(identifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 stack = useContext(FrameworkStackContext);
|
||||||
|
|
||||||
|
const services: any = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(identifiers)) {
|
||||||
|
let service;
|
||||||
|
for (let i = stack.length - 1; i >= 0; i--) {
|
||||||
|
service = stack[i].getOptional(value, {
|
||||||
|
sameScope: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
throw new ComponentNotFoundError(parseIdentifier(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
services[key.charAt(0).toLowerCase() + key.slice(1)] = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceOptional<T extends Service>(
|
||||||
|
identifier: Type<T>
|
||||||
|
): T | null {
|
||||||
|
const stack = useContext(FrameworkStackContext);
|
||||||
|
|
||||||
|
let service: T | null = null;
|
||||||
|
|
||||||
|
for (let i = stack.length - 1; i >= 0; i--) {
|
||||||
|
service = stack[i].getOptional(identifier, {
|
||||||
|
sameScope: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FrameworkRoot = ({
|
||||||
|
framework,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ framework: FrameworkProvider }>) => {
|
||||||
|
return (
|
||||||
|
<FrameworkStackContext.Provider value={[framework]}>
|
||||||
|
{children}
|
||||||
|
</FrameworkStackContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FrameworkScope = ({
|
||||||
|
scope,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ scope?: Scope }>) => {
|
||||||
|
const stack = useContext(FrameworkStackContext);
|
||||||
|
|
||||||
|
const nextStack = useMemo(() => {
|
||||||
|
if (!scope) return stack;
|
||||||
|
return [...stack, scope.framework];
|
||||||
|
}, [stack, scope]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FrameworkStackContext.Provider value={nextStack}>
|
||||||
|
{children}
|
||||||
|
</FrameworkStackContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,32 +2,40 @@ export * from './app-config-storage';
|
|||||||
export * from './atom';
|
export * from './atom';
|
||||||
export * from './blocksuite';
|
export * from './blocksuite';
|
||||||
export * from './command';
|
export * from './command';
|
||||||
export * from './di';
|
export * from './framework';
|
||||||
export * from './initialization';
|
export * from './initialization';
|
||||||
export * from './lifecycle';
|
|
||||||
export * from './livedata';
|
export * from './livedata';
|
||||||
export * from './page';
|
export * from './modules/doc';
|
||||||
|
export * from './modules/global-context';
|
||||||
|
export * from './modules/lifecycle';
|
||||||
|
export * from './modules/storage';
|
||||||
|
export * from './modules/workspace';
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
|
export * from './sync';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './workspace';
|
|
||||||
|
|
||||||
import type { ServiceCollection } from './di';
|
import type { Framework } from './framework';
|
||||||
import { CleanupService } from './lifecycle';
|
import { configureDocModule } from './modules/doc';
|
||||||
import { configurePageServices } from './page';
|
import { configureGlobalContextModule } from './modules/global-context';
|
||||||
import { GlobalCache, GlobalState, MemoryMemento } from './storage';
|
import { configureLifecycleModule } from './modules/lifecycle';
|
||||||
import {
|
import {
|
||||||
configureTestingWorkspaceServices,
|
configureGlobalStorageModule,
|
||||||
configureWorkspaceServices,
|
configureTestingGlobalStorage,
|
||||||
} from './workspace';
|
} from './modules/storage';
|
||||||
|
import {
|
||||||
|
configureTestingWorkspaceProvider,
|
||||||
|
configureWorkspaceModule,
|
||||||
|
} from './modules/workspace';
|
||||||
|
|
||||||
export function configureInfraServices(services: ServiceCollection) {
|
export function configureInfraModules(framework: Framework) {
|
||||||
services.add(CleanupService);
|
configureWorkspaceModule(framework);
|
||||||
configureWorkspaceServices(services);
|
configureDocModule(framework);
|
||||||
configurePageServices(services);
|
configureGlobalStorageModule(framework);
|
||||||
|
configureGlobalContextModule(framework);
|
||||||
|
configureLifecycleModule(framework);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureTestingInfraServices(services: ServiceCollection) {
|
export function configureTestingInfraModules(framework: Framework) {
|
||||||
configureTestingWorkspaceServices(services);
|
configureTestingGlobalStorage(framework);
|
||||||
services.override(GlobalCache, MemoryMemento);
|
configureTestingWorkspaceProvider(framework);
|
||||||
services.override(GlobalState, MemoryMemento);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
|
|
||||||
import { CleanupService } from '..';
|
|
||||||
|
|
||||||
describe('lifecycle', () => {
|
|
||||||
test('cleanup', () => {
|
|
||||||
const cleanup = new CleanupService();
|
|
||||||
let cleaned = false;
|
|
||||||
cleanup.add(() => {
|
|
||||||
cleaned = true;
|
|
||||||
});
|
|
||||||
cleanup.cleanup();
|
|
||||||
expect(cleaned).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export class CleanupService {
|
|
||||||
private readonly cleanupCallbacks: (() => void)[] = [];
|
|
||||||
constructor() {}
|
|
||||||
add(fn: () => void) {
|
|
||||||
this.cleanupCallbacks.push(fn);
|
|
||||||
}
|
|
||||||
cleanup() {
|
|
||||||
this.cleanupCallbacks.forEach(fn => fn());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -263,6 +263,13 @@ describe('livedata', () => {
|
|||||||
inner$.next(4);
|
inner$.next(4);
|
||||||
expect(flatten$.value).toEqual([4, 3]);
|
expect(flatten$.value).toEqual([4, 3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const wrapped$ = new LiveData([] as LiveData<number>[]);
|
||||||
|
const flatten$ = wrapped$.flat();
|
||||||
|
|
||||||
|
expect(flatten$.value).toEqual([]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('computed', () => {
|
test('computed', () => {
|
||||||
|
|||||||
@@ -4,10 +4,43 @@ import { type OperatorFunction, Subject } from 'rxjs';
|
|||||||
|
|
||||||
const logger = new DebugLogger('effect');
|
const logger = new DebugLogger('effect');
|
||||||
|
|
||||||
export interface Effect<T> {
|
export type Effect<T> = (T | undefined extends T // hack to detect if T is unknown
|
||||||
(value: T): void;
|
? () => void
|
||||||
}
|
: (value: T) => void) & {
|
||||||
|
// unsubscribe effect, all ongoing effects will be cancelled.
|
||||||
|
unsubscribe: () => 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>(op1: OperatorFunction<T, A>): Effect<T>;
|
||||||
export function effect<T, A, B>(
|
export function effect<T, A, B>(
|
||||||
op1: OperatorFunction<T, A>,
|
op1: OperatorFunction<T, A>,
|
||||||
@@ -42,23 +75,47 @@ export function effect<T, A, B, C, D, E, F>(
|
|||||||
export function effect(...args: any[]) {
|
export function effect(...args: any[]) {
|
||||||
const subject$ = new Subject<any>();
|
const subject$ = new Subject<any>();
|
||||||
|
|
||||||
|
const effectLocation = environment.isDebug
|
||||||
|
? `(${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 + '') : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-spread
|
// eslint-disable-next-line prefer-spread
|
||||||
subject$.pipe.apply(subject$, args as any).subscribe({
|
const subscription = subject$.pipe.apply(subject$, args as any).subscribe({
|
||||||
next(value) {
|
next(value) {
|
||||||
logger.error('effect should not emit value', value);
|
const error = new EffectError('should not emit value', value);
|
||||||
throw new Unreachable('effect should not emit value');
|
setImmediate(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
complete() {
|
complete() {
|
||||||
logger.error('effect unexpected complete');
|
const error = new EffectError('effect unexpected complete');
|
||||||
throw new Unreachable('effect unexpected complete');
|
setImmediate(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error(error) {
|
error(error) {
|
||||||
logger.error('effect uncatched error', error);
|
const effectError = new EffectError('effect uncaught error', error);
|
||||||
throw new Unreachable('effect uncatched error');
|
setImmediate(() => {
|
||||||
|
throw effectError;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return ((value: unknown) => {
|
const fn = (value: unknown) => {
|
||||||
subject$.next(value);
|
subject$.next(value);
|
||||||
}) as never;
|
};
|
||||||
|
|
||||||
|
fn.unsubscribe = () => subscription.unsubscribe();
|
||||||
|
|
||||||
|
return fn as never;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
export { type Effect, effect } from './effect';
|
export { type Effect, effect } from './effect';
|
||||||
export { LiveData, PoisonedError } from './livedata';
|
export { LiveData, PoisonedError } from './livedata';
|
||||||
export { catchErrorInto, mapInto, onComplete, onStart } from './ops';
|
export {
|
||||||
|
backoffRetry,
|
||||||
|
catchErrorInto,
|
||||||
|
exhaustMapSwitchUntilChanged,
|
||||||
|
fromPromise,
|
||||||
|
mapInto,
|
||||||
|
onComplete,
|
||||||
|
onStart,
|
||||||
|
} from './ops';
|
||||||
export { useEnsureLiveData, useLiveData } from './react';
|
export { useEnsureLiveData, useLiveData } from './react';
|
||||||
|
|||||||
@@ -428,6 +428,9 @@ export class LiveData<T = unknown>
|
|||||||
if (v instanceof LiveData) {
|
if (v instanceof LiveData) {
|
||||||
return (v as LiveData<any>).flat();
|
return (v as LiveData<any>).flat();
|
||||||
} else if (Array.isArray(v)) {
|
} else if (Array.isArray(v)) {
|
||||||
|
if (v.length === 0) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
v.map(v => {
|
v.map(v => {
|
||||||
if (v instanceof LiveData) {
|
if (v instanceof LiveData) {
|
||||||
@@ -446,6 +449,29 @@ export class LiveData<T = unknown>
|
|||||||
) as any;
|
) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
setImmediate(() => {
|
||||||
|
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) => {
|
reactSubscribe = (cb: () => void) => {
|
||||||
if (this.isPoisoned) {
|
if (this.isPoisoned) {
|
||||||
throw this.poisonedError;
|
throw this.poisonedError;
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
|
connect,
|
||||||
|
distinctUntilChanged,
|
||||||
EMPTY,
|
EMPTY,
|
||||||
|
exhaustMap,
|
||||||
|
merge,
|
||||||
mergeMap,
|
mergeMap,
|
||||||
Observable,
|
Observable,
|
||||||
|
type ObservableInput,
|
||||||
|
type ObservedValueOf,
|
||||||
|
of,
|
||||||
type OperatorFunction,
|
type OperatorFunction,
|
||||||
pipe,
|
pipe,
|
||||||
|
retry,
|
||||||
|
switchMap,
|
||||||
|
throwError,
|
||||||
|
timer,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import type { LiveData } from './livedata';
|
import type { LiveData } from './livedata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An operator that maps the value to the `LiveData`.
|
||||||
|
*/
|
||||||
export function mapInto<T>(l$: LiveData<T>) {
|
export function mapInto<T>(l$: LiveData<T>) {
|
||||||
return pipe(
|
return pipe(
|
||||||
mergeMap((value: T) => {
|
mergeMap((value: T) => {
|
||||||
@@ -18,15 +32,30 @@ export function mapInto<T>(l$: LiveData<T>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function catchErrorInto(l$: LiveData<any>) {
|
/**
|
||||||
|
* 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(
|
return pipe(
|
||||||
|
onComplete(() => l$.next(null)),
|
||||||
catchError((error: any) => {
|
catchError((error: any) => {
|
||||||
l$.next(error);
|
l$.next(error);
|
||||||
|
cb?.(error);
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An operator that calls the callback when the observable starts.
|
||||||
|
*/
|
||||||
export function onStart<T>(cb: () => void): OperatorFunction<T, T> {
|
export function onStart<T>(cb: () => void): OperatorFunction<T, T> {
|
||||||
return observable$ =>
|
return observable$ =>
|
||||||
new Observable(subscribe => {
|
new Observable(subscribe => {
|
||||||
@@ -35,6 +64,9 @@ export function onStart<T>(cb: () => void): OperatorFunction<T, T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An operator that calls the callback when the observable completes.
|
||||||
|
*/
|
||||||
export function onComplete<T>(cb: () => void): OperatorFunction<T, T> {
|
export function onComplete<T>(cb: () => void): OperatorFunction<T, T> {
|
||||||
return observable$ =>
|
return observable$ =>
|
||||||
new Observable(subscribe => {
|
new Observable(subscribe => {
|
||||||
@@ -52,3 +84,95 @@ export function onComplete<T>(cb: () => void): OperatorFunction<T, T> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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('Aborted');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
28
packages/common/infra/src/modules/doc/entities/doc.ts
Normal file
28
packages/common/infra/src/modules/doc/entities/doc.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import type { DocScope } from '../scopes/doc';
|
||||||
|
import type { DocMode } from './record';
|
||||||
|
|
||||||
|
export class Doc extends Entity {
|
||||||
|
constructor(public readonly scope: DocScope) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
return this.scope.props.docId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly blockSuiteDoc = this.scope.props.blockSuiteDoc;
|
||||||
|
public readonly record = this.scope.props.record;
|
||||||
|
|
||||||
|
readonly meta$ = this.record.meta$;
|
||||||
|
readonly mode$ = this.record.mode$;
|
||||||
|
readonly title$ = this.record.title$;
|
||||||
|
|
||||||
|
setMode(mode: DocMode) {
|
||||||
|
this.record.setMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMode() {
|
||||||
|
this.record.toggleMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { map } from 'rxjs';
|
||||||
|
|
||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { LiveData } from '../../../livedata';
|
||||||
|
import type { DocsStore } from '../stores/docs';
|
||||||
|
import { DocRecord } from './record';
|
||||||
|
|
||||||
|
export class DocRecordList extends Entity {
|
||||||
|
constructor(private readonly store: DocsStore) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly pool = new Map<string, DocRecord>();
|
||||||
|
|
||||||
|
public readonly docs$ = LiveData.from<DocRecord[]>(
|
||||||
|
this.store.watchDocIds().pipe(
|
||||||
|
map(ids =>
|
||||||
|
ids.map(id => {
|
||||||
|
const exists = this.pool.get(id);
|
||||||
|
if (exists) {
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
const record = this.framework.createEntity(DocRecord, { id });
|
||||||
|
this.pool.set(id, record);
|
||||||
|
return record;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
public readonly isReady$ = LiveData.from(
|
||||||
|
this.store.watchDocListReady(),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
public doc$(id: string) {
|
||||||
|
return this.docs$.map(record => record.find(record => record.id === id));
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/common/infra/src/modules/doc/entities/record.ts
Normal file
45
packages/common/infra/src/modules/doc/entities/record.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { DocMeta } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { LiveData } from '../../../livedata';
|
||||||
|
import type { DocsStore } from '../stores/docs';
|
||||||
|
|
||||||
|
export type DocMode = 'edgeless' | 'page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # DocRecord
|
||||||
|
*
|
||||||
|
* Some data you can use without open a doc.
|
||||||
|
*/
|
||||||
|
export class DocRecord extends Entity<{ id: string }> {
|
||||||
|
id: string = this.props.id;
|
||||||
|
meta: Partial<DocMeta> | null = null;
|
||||||
|
constructor(private readonly docsStore: DocsStore) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
meta$ = LiveData.from<Partial<DocMeta>>(
|
||||||
|
this.docsStore.watchDocMeta(this.id),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
setMeta(meta: Partial<DocMeta>): void {
|
||||||
|
this.docsStore.setDocMeta(this.id, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
mode$: LiveData<DocMode> = LiveData.from(
|
||||||
|
this.docsStore.watchDocModeSetting(this.id),
|
||||||
|
'page'
|
||||||
|
).map(mode => (mode === 'edgeless' ? 'edgeless' : 'page'));
|
||||||
|
|
||||||
|
setMode(mode: DocMode) {
|
||||||
|
this.docsStore.setDocModeSetting(this.id, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMode() {
|
||||||
|
this.setMode(this.mode$.value === 'edgeless' ? 'page' : 'edgeless');
|
||||||
|
return this.mode$.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
title$ = this.meta$.map(meta => meta.title ?? '');
|
||||||
|
}
|
||||||
33
packages/common/infra/src/modules/doc/index.ts
Normal file
33
packages/common/infra/src/modules/doc/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export { Doc } from './entities/doc';
|
||||||
|
export type { DocMode } from './entities/record';
|
||||||
|
export { DocRecord } from './entities/record';
|
||||||
|
export { DocRecordList } from './entities/record-list';
|
||||||
|
export { DocScope } from './scopes/doc';
|
||||||
|
export { DocService } from './services/doc';
|
||||||
|
export { DocsService } from './services/docs';
|
||||||
|
|
||||||
|
import type { Framework } from '../../framework';
|
||||||
|
import {
|
||||||
|
WorkspaceLocalState,
|
||||||
|
WorkspaceScope,
|
||||||
|
WorkspaceService,
|
||||||
|
} from '../workspace';
|
||||||
|
import { Doc } from './entities/doc';
|
||||||
|
import { DocRecord } from './entities/record';
|
||||||
|
import { DocRecordList } from './entities/record-list';
|
||||||
|
import { DocScope } from './scopes/doc';
|
||||||
|
import { DocService } from './services/doc';
|
||||||
|
import { DocsService } from './services/docs';
|
||||||
|
import { DocsStore } from './stores/docs';
|
||||||
|
|
||||||
|
export function configureDocModule(framework: Framework) {
|
||||||
|
framework
|
||||||
|
.scope(WorkspaceScope)
|
||||||
|
.service(DocsService, [DocsStore])
|
||||||
|
.store(DocsStore, [WorkspaceService, WorkspaceLocalState])
|
||||||
|
.entity(DocRecord, [DocsStore])
|
||||||
|
.entity(DocRecordList, [DocsStore])
|
||||||
|
.scope(DocScope)
|
||||||
|
.entity(Doc, [DocScope])
|
||||||
|
.service(DocService);
|
||||||
|
}
|
||||||
10
packages/common/infra/src/modules/doc/scopes/doc.ts
Normal file
10
packages/common/infra/src/modules/doc/scopes/doc.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { Scope } from '../../../framework';
|
||||||
|
import type { DocRecord } from '../entities/record';
|
||||||
|
|
||||||
|
export class DocScope extends Scope<{
|
||||||
|
docId: string;
|
||||||
|
record: DocRecord;
|
||||||
|
blockSuiteDoc: BlockSuiteDoc;
|
||||||
|
}> {}
|
||||||
6
packages/common/infra/src/modules/doc/services/doc.ts
Normal file
6
packages/common/infra/src/modules/doc/services/doc.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { Doc } from '../entities/doc';
|
||||||
|
|
||||||
|
export class DocService extends Service {
|
||||||
|
public readonly doc = this.framework.createEntity(Doc);
|
||||||
|
}
|
||||||
49
packages/common/infra/src/modules/doc/services/docs.ts
Normal file
49
packages/common/infra/src/modules/doc/services/docs.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { ObjectPool } from '../../../utils';
|
||||||
|
import type { Doc } from '../entities/doc';
|
||||||
|
import { DocRecordList } from '../entities/record-list';
|
||||||
|
import { DocScope } from '../scopes/doc';
|
||||||
|
import type { DocsStore } from '../stores/docs';
|
||||||
|
import { DocService } from './doc';
|
||||||
|
|
||||||
|
export class DocsService extends Service {
|
||||||
|
list = this.framework.createEntity(DocRecordList);
|
||||||
|
|
||||||
|
pool = new ObjectPool<string, Doc>({
|
||||||
|
onDelete(obj) {
|
||||||
|
obj.scope.dispose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private readonly store: DocsStore) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
open(docId: string) {
|
||||||
|
const docRecord = this.list.doc$(docId).value;
|
||||||
|
if (!docRecord) {
|
||||||
|
throw new Error('Doc record not found');
|
||||||
|
}
|
||||||
|
const blockSuiteDoc = this.store.getBlockSuiteDoc(docId);
|
||||||
|
if (!blockSuiteDoc) {
|
||||||
|
throw new Error('Doc not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = this.pool.get(docId);
|
||||||
|
if (exists) {
|
||||||
|
return { doc: exists.obj, release: exists.release };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docScope = this.framework.createScope(DocScope, {
|
||||||
|
docId,
|
||||||
|
blockSuiteDoc,
|
||||||
|
record: docRecord,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = docScope.get(DocService).doc;
|
||||||
|
|
||||||
|
const { obj, release } = this.pool.put(docId, doc);
|
||||||
|
|
||||||
|
return { doc: obj, release };
|
||||||
|
}
|
||||||
|
}
|
||||||
85
packages/common/infra/src/modules/doc/stores/docs.ts
Normal file
85
packages/common/infra/src/modules/doc/stores/docs.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { type DocMeta } from '@blocksuite/store';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
import { distinctUntilChanged, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { Store } from '../../../framework';
|
||||||
|
import type { WorkspaceLocalState, WorkspaceService } from '../../workspace';
|
||||||
|
import type { DocMode } from '../entities/record';
|
||||||
|
|
||||||
|
export class DocsStore extends Store {
|
||||||
|
constructor(
|
||||||
|
private readonly workspaceService: WorkspaceService,
|
||||||
|
private readonly localState: WorkspaceLocalState
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
getBlockSuiteDoc(id: string) {
|
||||||
|
return this.workspaceService.workspace.docCollection.getDoc(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDocIds() {
|
||||||
|
return new Observable<string[]>(subscriber => {
|
||||||
|
const emit = () => {
|
||||||
|
subscriber.next(
|
||||||
|
this.workspaceService.workspace.docCollection.meta.docMetas.map(
|
||||||
|
v => v.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
emit();
|
||||||
|
|
||||||
|
const dispose =
|
||||||
|
this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on(
|
||||||
|
emit
|
||||||
|
).dispose;
|
||||||
|
return () => {
|
||||||
|
dispose();
|
||||||
|
};
|
||||||
|
}).pipe(distinctUntilChanged((p, c) => isEqual(p, c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDocMeta(id: string) {
|
||||||
|
let meta: DocMeta | null = null;
|
||||||
|
return new Observable<Partial<DocMeta>>(subscriber => {
|
||||||
|
const emit = () => {
|
||||||
|
if (meta === null) {
|
||||||
|
// getDocMeta is heavy, so we cache the doc meta reference
|
||||||
|
meta =
|
||||||
|
this.workspaceService.workspace.docCollection.meta.getDocMeta(id) ||
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
subscriber.next({ ...meta });
|
||||||
|
};
|
||||||
|
|
||||||
|
emit();
|
||||||
|
|
||||||
|
const dispose =
|
||||||
|
this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on(
|
||||||
|
emit
|
||||||
|
).dispose;
|
||||||
|
return () => {
|
||||||
|
dispose();
|
||||||
|
};
|
||||||
|
}).pipe(distinctUntilChanged((p, c) => isEqual(p, c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDocListReady() {
|
||||||
|
return this.workspaceService.workspace.engine.rootDocState$
|
||||||
|
.map(state => !state.syncing)
|
||||||
|
.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocMeta(id: string, meta: Partial<DocMeta>) {
|
||||||
|
this.workspaceService.workspace.docCollection.setDocMeta(id, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocModeSetting(id: string, mode: DocMode) {
|
||||||
|
this.localState.set(`page:${id}:mode`, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDocModeSetting(id: string) {
|
||||||
|
return this.localState.watch<DocMode>(`page:${id}:mode`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { LiveData } from '../../../livedata';
|
||||||
|
import { MemoryMemento } from '../../../storage';
|
||||||
|
import type { DocMode } from '../../doc';
|
||||||
|
|
||||||
|
export class GlobalContext extends Entity {
|
||||||
|
memento = new MemoryMemento();
|
||||||
|
|
||||||
|
workspaceId = this.define<string>('workspaceId');
|
||||||
|
|
||||||
|
docId = this.define<string>('docId');
|
||||||
|
|
||||||
|
docMode = this.define<DocMode>('docMode');
|
||||||
|
|
||||||
|
define<T>(key: string) {
|
||||||
|
this.memento.set(key, null);
|
||||||
|
const livedata$ = LiveData.from(this.memento.watch<T>(key), null);
|
||||||
|
return {
|
||||||
|
get: () => this.memento.get(key) as T | null,
|
||||||
|
set: (value: T | null) => this.memento.set(key, value),
|
||||||
|
$: livedata$,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { GlobalContextService } from './services/global-context';
|
||||||
|
|
||||||
|
import type { Framework } from '../../framework';
|
||||||
|
import { GlobalContext } from './entities/global-context';
|
||||||
|
import { GlobalContextService } from './services/global-context';
|
||||||
|
|
||||||
|
export function configureGlobalContextModule(framework: Framework) {
|
||||||
|
framework.service(GlobalContextService).entity(GlobalContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { GlobalContext } from '../entities/global-context';
|
||||||
|
|
||||||
|
export class GlobalContextService extends Service {
|
||||||
|
globalContext = this.framework.createEntity(GlobalContext);
|
||||||
|
}
|
||||||
12
packages/common/infra/src/modules/lifecycle/index.ts
Normal file
12
packages/common/infra/src/modules/lifecycle/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Framework } from '../../framework';
|
||||||
|
import { LifecycleService } from './service/lifecycle';
|
||||||
|
|
||||||
|
export {
|
||||||
|
ApplicationFocused,
|
||||||
|
ApplicationStarted,
|
||||||
|
LifecycleService,
|
||||||
|
} from './service/lifecycle';
|
||||||
|
|
||||||
|
export function configureLifecycleModule(framework: Framework) {
|
||||||
|
framework.service(LifecycleService);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createEvent, Service } from '../../../framework';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event that is emitted when application is started.
|
||||||
|
*/
|
||||||
|
export const ApplicationStarted = createEvent<boolean>('ApplicationStartup');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event that is emitted when browser tab or windows is focused again, after being blurred.
|
||||||
|
* Can be used to actively refresh some data.
|
||||||
|
*/
|
||||||
|
export const ApplicationFocused = createEvent<boolean>('ApplicationFocused');
|
||||||
|
|
||||||
|
export class LifecycleService extends Service {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationStart() {
|
||||||
|
this.eventBus.emit(ApplicationStarted, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationFocus() {
|
||||||
|
this.eventBus.emit(ApplicationFocused, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/common/infra/src/modules/storage/index.ts
Normal file
17
packages/common/infra/src/modules/storage/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { GlobalCache, GlobalState } from './providers/global';
|
||||||
|
export { GlobalCacheService, GlobalStateService } from './services/global';
|
||||||
|
|
||||||
|
import type { Framework } from '../../framework';
|
||||||
|
import { MemoryMemento } from '../../storage';
|
||||||
|
import { GlobalCache, GlobalState } from './providers/global';
|
||||||
|
import { GlobalCacheService, GlobalStateService } from './services/global';
|
||||||
|
|
||||||
|
export const configureGlobalStorageModule = (framework: Framework) => {
|
||||||
|
framework.service(GlobalStateService, [GlobalState]);
|
||||||
|
framework.service(GlobalCacheService, [GlobalCache]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const configureTestingGlobalStorage = (framework: Framework) => {
|
||||||
|
framework.impl(GlobalCache, MemoryMemento);
|
||||||
|
framework.impl(GlobalState, MemoryMemento);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createIdentifier } from '../../../framework';
|
||||||
|
import type { Memento } from '../../../storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A memento object that stores the entire application state.
|
||||||
|
*
|
||||||
|
* State is persisted, even the application is closed.
|
||||||
|
*/
|
||||||
|
export interface GlobalState extends Memento {}
|
||||||
|
|
||||||
|
export const GlobalState = createIdentifier<GlobalState>('GlobalState');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A memento object that stores the entire application cache.
|
||||||
|
*
|
||||||
|
* Cache may be deleted from time to time, business logic should not rely on cache.
|
||||||
|
*/
|
||||||
|
export interface GlobalCache extends Memento {}
|
||||||
|
|
||||||
|
export const GlobalCache = createIdentifier<GlobalCache>('GlobalCache');
|
||||||
14
packages/common/infra/src/modules/storage/services/global.ts
Normal file
14
packages/common/infra/src/modules/storage/services/global.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import type { GlobalCache, GlobalState } from '../providers/global';
|
||||||
|
|
||||||
|
export class GlobalStateService extends Service {
|
||||||
|
constructor(public readonly globalState: GlobalState) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalCacheService extends Service {
|
||||||
|
constructor(public readonly globalCache: GlobalCache) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { Framework } from '../../../framework';
|
||||||
|
import { configureTestingGlobalStorage } from '../../storage';
|
||||||
|
import {
|
||||||
|
configureTestingWorkspaceProvider,
|
||||||
|
configureWorkspaceModule,
|
||||||
|
Workspace,
|
||||||
|
WorkspacesService,
|
||||||
|
} from '..';
|
||||||
|
|
||||||
|
describe('Workspace System', () => {
|
||||||
|
test('create workspace', async () => {
|
||||||
|
const framework = new Framework();
|
||||||
|
configureTestingGlobalStorage(framework);
|
||||||
|
configureWorkspaceModule(framework);
|
||||||
|
configureTestingWorkspaceProvider(framework);
|
||||||
|
|
||||||
|
const provider = framework.provider();
|
||||||
|
const workspaceService = provider.get(WorkspacesService);
|
||||||
|
expect(workspaceService.list.workspaces$.value.length).toBe(0);
|
||||||
|
|
||||||
|
const workspace = workspaceService.open({
|
||||||
|
metadata: await workspaceService.create(WorkspaceFlavour.LOCAL),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspace.workspace).toBeInstanceOf(Workspace);
|
||||||
|
|
||||||
|
expect(workspaceService.list.workspaces$.value.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { AwarenessEngine, BlobEngine, DocEngine } from '../../../sync';
|
||||||
|
import { throwIfAborted } from '../../../utils';
|
||||||
|
import type { WorkspaceEngineProvider } from '../providers/flavour';
|
||||||
|
import type { WorkspaceService } from '../services/workspace';
|
||||||
|
|
||||||
|
export class WorkspaceEngine extends Entity<{
|
||||||
|
engineProvider: WorkspaceEngineProvider;
|
||||||
|
}> {
|
||||||
|
doc = new DocEngine(
|
||||||
|
this.props.engineProvider.getDocStorage(),
|
||||||
|
this.props.engineProvider.getDocServer()
|
||||||
|
);
|
||||||
|
|
||||||
|
blob = new BlobEngine(
|
||||||
|
this.props.engineProvider.getLocalBlobStorage(),
|
||||||
|
this.props.engineProvider.getRemoteBlobStorages()
|
||||||
|
);
|
||||||
|
|
||||||
|
awareness = new AwarenessEngine(
|
||||||
|
this.props.engineProvider.getAwarenessConnections()
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(private readonly workspaceService: WorkspaceService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRootDoc(yDoc: YDoc) {
|
||||||
|
this.doc.setPriority(yDoc.guid, 100);
|
||||||
|
this.doc.addDoc(yDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.doc.start();
|
||||||
|
this.awareness.connect();
|
||||||
|
this.blob.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
canGracefulStop() {
|
||||||
|
return this.doc.engineState$.value.saving === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForGracefulStop(abort?: AbortSignal) {
|
||||||
|
await this.doc.waitForSaved();
|
||||||
|
throwIfAborted(abort);
|
||||||
|
this.forceStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
forceStop() {
|
||||||
|
this.doc.stop();
|
||||||
|
this.awareness.disconnect();
|
||||||
|
this.blob.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
docEngineState$ = this.doc.engineState$;
|
||||||
|
|
||||||
|
rootDocState$ = this.doc.docState$(this.workspaceService.workspace.id);
|
||||||
|
|
||||||
|
waitForDocSynced() {
|
||||||
|
return this.doc.waitForSynced();
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForRootDocReady() {
|
||||||
|
return this.doc.waitForReady(this.workspaceService.workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.forceStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/common/infra/src/modules/workspace/entities/list.ts
Normal file
27
packages/common/infra/src/modules/workspace/entities/list.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { LiveData } from '../../../livedata';
|
||||||
|
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||||
|
|
||||||
|
export class WorkspaceList extends Entity {
|
||||||
|
workspaces$ = new LiveData(this.providers.map(p => p.workspaces$))
|
||||||
|
.map(v => {
|
||||||
|
return v;
|
||||||
|
})
|
||||||
|
.flat()
|
||||||
|
.map(workspaces => {
|
||||||
|
return workspaces.flat();
|
||||||
|
});
|
||||||
|
isLoading$ = new LiveData(
|
||||||
|
this.providers.map(p => p.isLoading$ ?? new LiveData(false))
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.map(isLoadings => isLoadings.some(isLoading => isLoading));
|
||||||
|
|
||||||
|
constructor(private readonly providers: WorkspaceFlavourProvider[]) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate() {
|
||||||
|
this.providers.forEach(provider => provider.revalidate?.());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import { catchError, EMPTY, from, mergeMap, switchMap } from 'rxjs';
|
||||||
|
|
||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { effect, LiveData, onComplete, onStart } from '../../../livedata';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
|
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||||
|
import type { WorkspaceProfileCacheStore } from '../stores/profile-cache';
|
||||||
|
import type { Workspace } from './workspace';
|
||||||
|
|
||||||
|
const logger = new DebugLogger('affine:workspace-profile');
|
||||||
|
|
||||||
|
export interface WorkspaceProfileInfo {
|
||||||
|
avatar?: string;
|
||||||
|
name?: string;
|
||||||
|
isOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # WorkspaceProfile
|
||||||
|
*
|
||||||
|
* This class take care of workspace avatar and name
|
||||||
|
*/
|
||||||
|
export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
|
||||||
|
private readonly provider: WorkspaceFlavourProvider | null;
|
||||||
|
|
||||||
|
get id() {
|
||||||
|
return this.props.metadata.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile$ = LiveData.from<WorkspaceProfileInfo | null>(
|
||||||
|
this.cache.watchProfileCache(this.props.metadata.id),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
avatar$ = this.profile$.map(v => v?.avatar);
|
||||||
|
name$ = this.profile$.map(v => v?.name);
|
||||||
|
|
||||||
|
isLoading$ = new LiveData(false);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cache: WorkspaceProfileCacheStore,
|
||||||
|
providers: WorkspaceFlavourProvider[]
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.provider =
|
||||||
|
providers.find(p => p.flavour === this.props.metadata.flavour) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCache(info: WorkspaceProfileInfo) {
|
||||||
|
this.cache.setProfileCache(this.props.metadata.id, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate = effect(
|
||||||
|
switchMap(() => {
|
||||||
|
if (!this.provider) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
return from(
|
||||||
|
this.provider.getWorkspaceProfile(this.props.metadata.id)
|
||||||
|
).pipe(
|
||||||
|
mergeMap(info => {
|
||||||
|
if (info) {
|
||||||
|
this.setCache({ ...this.profile$.value, ...info });
|
||||||
|
}
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
catchError(err => {
|
||||||
|
logger.error(err);
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
onStart(() => this.isLoading$.next(true)),
|
||||||
|
onComplete(() => this.isLoading$.next(false))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
syncWithWorkspace(workspace: Workspace) {
|
||||||
|
workspace.name$.subscribe(name => {
|
||||||
|
const old = this.profile$.value;
|
||||||
|
this.setCache({ ...old, name: name ?? old?.name });
|
||||||
|
});
|
||||||
|
workspace.avatar$.subscribe(avatar => {
|
||||||
|
const old = this.profile$.value;
|
||||||
|
this.setCache({ ...old, avatar: avatar ?? old?.avatar });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
135
packages/common/infra/src/modules/workspace/entities/upgrade.ts
Normal file
135
packages/common/infra/src/modules/workspace/entities/upgrade.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Unreachable } from '@affine/env/constant';
|
||||||
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
|
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
checkWorkspaceCompatibility,
|
||||||
|
forceUpgradePages,
|
||||||
|
migrateGuidCompatibility,
|
||||||
|
MigrationPoint,
|
||||||
|
upgradeV1ToV2,
|
||||||
|
} from '../../../blocksuite';
|
||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { LiveData } from '../../../livedata';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
|
import type { WorkspaceDestroyService } from '../services/destroy';
|
||||||
|
import type { WorkspaceFactoryService } from '../services/factory';
|
||||||
|
import type { WorkspaceService } from '../services/workspace';
|
||||||
|
|
||||||
|
export class WorkspaceUpgrade extends Entity {
|
||||||
|
needUpgrade$ = new LiveData(false);
|
||||||
|
upgrading$ = new LiveData(false);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly workspaceService: WorkspaceService,
|
||||||
|
private readonly workspaceFactory: WorkspaceFactoryService,
|
||||||
|
private readonly workspaceDestroy: WorkspaceDestroyService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.checkIfNeedUpgrade();
|
||||||
|
workspaceService.workspace.docCollection.doc.on('update', () => {
|
||||||
|
this.checkIfNeedUpgrade();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkIfNeedUpgrade() {
|
||||||
|
const needUpgrade = !!checkWorkspaceCompatibility(
|
||||||
|
this.workspaceService.workspace.docCollection,
|
||||||
|
this.workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD
|
||||||
|
);
|
||||||
|
this.needUpgrade$.next(needUpgrade);
|
||||||
|
return needUpgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upgrade(): Promise<WorkspaceMetadata | null> {
|
||||||
|
if (this.upgrading$.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.upgrading$.next(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.workspaceService.workspace.engine.waitForDocSynced();
|
||||||
|
|
||||||
|
const step = checkWorkspaceCompatibility(
|
||||||
|
this.workspaceService.workspace.docCollection,
|
||||||
|
this.workspaceService.workspace.flavour ===
|
||||||
|
WorkspaceFlavour.AFFINE_CLOUD
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone a new doc to prevent change events.
|
||||||
|
const clonedDoc = new YDoc({
|
||||||
|
guid: this.workspaceService.workspace.docCollection.doc.guid,
|
||||||
|
});
|
||||||
|
applyDoc(clonedDoc, this.workspaceService.workspace.docCollection.doc);
|
||||||
|
|
||||||
|
if (step === MigrationPoint.SubDoc) {
|
||||||
|
const newWorkspace = await this.workspaceFactory.create(
|
||||||
|
WorkspaceFlavour.LOCAL,
|
||||||
|
async (workspace, blobStorage) => {
|
||||||
|
await upgradeV1ToV2(clonedDoc, workspace.doc);
|
||||||
|
migrateGuidCompatibility(clonedDoc);
|
||||||
|
await forceUpgradePages(
|
||||||
|
workspace.doc,
|
||||||
|
this.workspaceService.workspace.docCollection.schema
|
||||||
|
);
|
||||||
|
const blobList =
|
||||||
|
await this.workspaceService.workspace.docCollection.blob.list();
|
||||||
|
|
||||||
|
for (const blobKey of blobList) {
|
||||||
|
const blob =
|
||||||
|
await this.workspaceService.workspace.docCollection.blob.get(
|
||||||
|
blobKey
|
||||||
|
);
|
||||||
|
if (blob) {
|
||||||
|
await blobStorage.set(blobKey, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.workspaceDestroy.deleteWorkspace(
|
||||||
|
this.workspaceService.workspace.meta
|
||||||
|
);
|
||||||
|
return newWorkspace;
|
||||||
|
} else if (step === MigrationPoint.GuidFix) {
|
||||||
|
migrateGuidCompatibility(clonedDoc);
|
||||||
|
await forceUpgradePages(
|
||||||
|
clonedDoc,
|
||||||
|
this.workspaceService.workspace.docCollection.schema
|
||||||
|
);
|
||||||
|
applyDoc(this.workspaceService.workspace.docCollection.doc, clonedDoc);
|
||||||
|
await this.workspaceService.workspace.engine.waitForDocSynced();
|
||||||
|
return null;
|
||||||
|
} else if (step === MigrationPoint.BlockVersion) {
|
||||||
|
await forceUpgradePages(
|
||||||
|
clonedDoc,
|
||||||
|
this.workspaceService.workspace.docCollection.schema
|
||||||
|
);
|
||||||
|
applyDoc(this.workspaceService.workspace.docCollection.doc, clonedDoc);
|
||||||
|
await this.workspaceService.workspace.engine.waitForDocSynced();
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw new Unreachable();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.upgrading$.next(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDoc(target: YDoc, result: YDoc) {
|
||||||
|
applyUpdate(target, encodeStateAsUpdate(result));
|
||||||
|
for (const targetSubDoc of target.subdocs.values()) {
|
||||||
|
const resultSubDocs = Array.from(result.subdocs.values());
|
||||||
|
const resultSubDoc = resultSubDocs.find(
|
||||||
|
item => item.guid === targetSubDoc.guid
|
||||||
|
);
|
||||||
|
if (resultSubDoc) {
|
||||||
|
applyDoc(targetSubDoc, resultSubDoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { DocCollection } from '@blocksuite/store';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import type { Awareness } from 'y-protocols/awareness.js';
|
||||||
|
|
||||||
|
import { Entity } from '../../../framework';
|
||||||
|
import { LiveData } from '../../../livedata';
|
||||||
|
import { globalBlockSuiteSchema } from '../global-schema';
|
||||||
|
import type { WorkspaceScope } from '../scopes/workspace';
|
||||||
|
import { WorkspaceEngineService } from '../services/engine';
|
||||||
|
import { WorkspaceUpgradeService } from '../services/upgrade';
|
||||||
|
|
||||||
|
export class Workspace extends Entity {
|
||||||
|
constructor(public readonly scope: WorkspaceScope) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly id = this.scope.props.openOptions.metadata.id;
|
||||||
|
|
||||||
|
readonly openOptions = this.scope.props.openOptions;
|
||||||
|
|
||||||
|
readonly meta = this.scope.props.openOptions.metadata;
|
||||||
|
|
||||||
|
readonly flavour = this.meta.flavour;
|
||||||
|
|
||||||
|
_docCollection: DocCollection | null = null;
|
||||||
|
|
||||||
|
get docCollection() {
|
||||||
|
if (!this._docCollection) {
|
||||||
|
this._docCollection = new DocCollection({
|
||||||
|
id: this.openOptions.metadata.id,
|
||||||
|
blobStorages: [
|
||||||
|
() => ({
|
||||||
|
crud: {
|
||||||
|
get: key => {
|
||||||
|
return this.engine.blob.get(key);
|
||||||
|
},
|
||||||
|
set: (key, value) => {
|
||||||
|
return this.engine.blob.set(key, value);
|
||||||
|
},
|
||||||
|
list: () => {
|
||||||
|
return this.engine.blob.list();
|
||||||
|
},
|
||||||
|
delete: key => {
|
||||||
|
return this.engine.blob.delete(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
idGenerator: () => nanoid(),
|
||||||
|
schema: globalBlockSuiteSchema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this._docCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
get awareness() {
|
||||||
|
return this.docCollection.awarenessStore.awareness as Awareness;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rootYDoc() {
|
||||||
|
return this.docCollection.doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canGracefulStop() {
|
||||||
|
// TODO
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get engine() {
|
||||||
|
return this.framework.get(WorkspaceEngineService).engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
get upgrade() {
|
||||||
|
return this.framework.get(WorkspaceUpgradeService).upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
get flavourProvider() {
|
||||||
|
return this.scope.props.flavourProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
name$ = LiveData.from<string | undefined>(
|
||||||
|
new Observable(subscriber => {
|
||||||
|
subscriber.next(this.docCollection.meta.name);
|
||||||
|
return this.docCollection.meta.commonFieldsUpdated.on(() => {
|
||||||
|
subscriber.next(this.docCollection.meta.name);
|
||||||
|
}).dispose;
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
avatar$ = LiveData.from<string | undefined>(
|
||||||
|
new Observable(subscriber => {
|
||||||
|
subscriber.next(this.docCollection.meta.avatar);
|
||||||
|
return this.docCollection.meta.commonFieldsUpdated.on(() => {
|
||||||
|
subscriber.next(this.docCollection.meta.avatar);
|
||||||
|
}).dispose;
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
75
packages/common/infra/src/modules/workspace/impls/storage.ts
Normal file
75
packages/common/infra/src/modules/workspace/impls/storage.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { type Memento, wrapMemento } from '../../../storage';
|
||||||
|
import type { GlobalCache, GlobalState } from '../../storage';
|
||||||
|
import type {
|
||||||
|
WorkspaceLocalCache,
|
||||||
|
WorkspaceLocalState,
|
||||||
|
} from '../providers/storage';
|
||||||
|
import type { WorkspaceService } from '../services/workspace';
|
||||||
|
|
||||||
|
export class WorkspaceLocalStateImpl implements WorkspaceLocalState {
|
||||||
|
wrapped: Memento;
|
||||||
|
constructor(workspaceService: WorkspaceService, globalState: GlobalState) {
|
||||||
|
this.wrapped = wrapMemento(
|
||||||
|
globalState,
|
||||||
|
`workspace-state:${workspaceService.workspace.id}:`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
keys(): string[] {
|
||||||
|
return this.wrapped.keys();
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
return this.wrapped.get<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch<T>(key: string) {
|
||||||
|
return this.wrapped.watch<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
set<T>(key: string, value: T | null): void {
|
||||||
|
return this.wrapped.set<T>(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
del(key: string): void {
|
||||||
|
return this.wrapped.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
return this.wrapped.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceLocalCacheImpl implements WorkspaceLocalCache {
|
||||||
|
wrapped: Memento;
|
||||||
|
constructor(workspaceService: WorkspaceService, globalCache: GlobalCache) {
|
||||||
|
this.wrapped = wrapMemento(
|
||||||
|
globalCache,
|
||||||
|
`workspace-cache:${workspaceService.workspace.id}:`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
keys(): string[] {
|
||||||
|
return this.wrapped.keys();
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
return this.wrapped.get<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch<T>(key: string) {
|
||||||
|
return this.wrapped.watch<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
set<T>(key: string, value: T | null): void {
|
||||||
|
return this.wrapped.set<T>(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
del(key: string): void {
|
||||||
|
return this.wrapped.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
return this.wrapped.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
96
packages/common/infra/src/modules/workspace/index.ts
Normal file
96
packages/common/infra/src/modules/workspace/index.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
export type { WorkspaceProfileInfo } from './entities/profile';
|
||||||
|
export { Workspace } from './entities/workspace';
|
||||||
|
export { globalBlockSuiteSchema } from './global-schema';
|
||||||
|
export type { WorkspaceMetadata } from './metadata';
|
||||||
|
export type { WorkspaceOpenOptions } from './open-options';
|
||||||
|
export type { WorkspaceEngineProvider } from './providers/flavour';
|
||||||
|
export { WorkspaceFlavourProvider } from './providers/flavour';
|
||||||
|
export { WorkspaceLocalCache, WorkspaceLocalState } from './providers/storage';
|
||||||
|
export { WorkspaceScope } from './scopes/workspace';
|
||||||
|
export { WorkspaceService } from './services/workspace';
|
||||||
|
export { WorkspacesService } from './services/workspaces';
|
||||||
|
|
||||||
|
import type { Framework } from '../../framework';
|
||||||
|
import { GlobalCache, GlobalState } from '../storage';
|
||||||
|
import { WorkspaceEngine } from './entities/engine';
|
||||||
|
import { WorkspaceList } from './entities/list';
|
||||||
|
import { WorkspaceProfile } from './entities/profile';
|
||||||
|
import { WorkspaceUpgrade } from './entities/upgrade';
|
||||||
|
import { Workspace } from './entities/workspace';
|
||||||
|
import {
|
||||||
|
WorkspaceLocalCacheImpl,
|
||||||
|
WorkspaceLocalStateImpl,
|
||||||
|
} from './impls/storage';
|
||||||
|
import { WorkspaceFlavourProvider } from './providers/flavour';
|
||||||
|
import { WorkspaceLocalCache, WorkspaceLocalState } from './providers/storage';
|
||||||
|
import { WorkspaceScope } from './scopes/workspace';
|
||||||
|
import { WorkspaceDestroyService } from './services/destroy';
|
||||||
|
import { WorkspaceEngineService } from './services/engine';
|
||||||
|
import { WorkspaceFactoryService } from './services/factory';
|
||||||
|
import { WorkspaceListService } from './services/list';
|
||||||
|
import { WorkspaceProfileService } from './services/profile';
|
||||||
|
import { WorkspaceRepositoryService } from './services/repo';
|
||||||
|
import { WorkspaceTransformService } from './services/transform';
|
||||||
|
import { WorkspaceUpgradeService } from './services/upgrade';
|
||||||
|
import { WorkspaceService } from './services/workspace';
|
||||||
|
import { WorkspacesService } from './services/workspaces';
|
||||||
|
import { WorkspaceProfileCacheStore } from './stores/profile-cache';
|
||||||
|
import { TestingWorkspaceLocalProvider } from './testing/testing-provider';
|
||||||
|
|
||||||
|
export function configureWorkspaceModule(framework: Framework) {
|
||||||
|
framework
|
||||||
|
.service(WorkspacesService, [
|
||||||
|
[WorkspaceFlavourProvider],
|
||||||
|
WorkspaceListService,
|
||||||
|
WorkspaceProfileService,
|
||||||
|
WorkspaceTransformService,
|
||||||
|
WorkspaceRepositoryService,
|
||||||
|
WorkspaceFactoryService,
|
||||||
|
WorkspaceDestroyService,
|
||||||
|
])
|
||||||
|
.service(WorkspaceDestroyService, [[WorkspaceFlavourProvider]])
|
||||||
|
.service(WorkspaceListService)
|
||||||
|
.entity(WorkspaceList, [[WorkspaceFlavourProvider]])
|
||||||
|
.service(WorkspaceProfileService)
|
||||||
|
.store(WorkspaceProfileCacheStore, [GlobalCache])
|
||||||
|
.entity(WorkspaceProfile, [
|
||||||
|
WorkspaceProfileCacheStore,
|
||||||
|
[WorkspaceFlavourProvider],
|
||||||
|
])
|
||||||
|
.service(WorkspaceFactoryService, [[WorkspaceFlavourProvider]])
|
||||||
|
.service(WorkspaceTransformService, [
|
||||||
|
WorkspaceFactoryService,
|
||||||
|
WorkspaceDestroyService,
|
||||||
|
])
|
||||||
|
.service(WorkspaceRepositoryService, [
|
||||||
|
[WorkspaceFlavourProvider],
|
||||||
|
WorkspaceProfileService,
|
||||||
|
])
|
||||||
|
.scope(WorkspaceScope)
|
||||||
|
.service(WorkspaceService)
|
||||||
|
.entity(Workspace, [WorkspaceScope])
|
||||||
|
.service(WorkspaceEngineService, [WorkspaceService])
|
||||||
|
.entity(WorkspaceEngine, [WorkspaceService])
|
||||||
|
.service(WorkspaceUpgradeService)
|
||||||
|
.entity(WorkspaceUpgrade, [
|
||||||
|
WorkspaceService,
|
||||||
|
WorkspaceFactoryService,
|
||||||
|
WorkspaceDestroyService,
|
||||||
|
])
|
||||||
|
.impl(WorkspaceLocalState, WorkspaceLocalStateImpl, [
|
||||||
|
WorkspaceService,
|
||||||
|
GlobalState,
|
||||||
|
])
|
||||||
|
.impl(WorkspaceLocalCache, WorkspaceLocalCacheImpl, [
|
||||||
|
WorkspaceService,
|
||||||
|
GlobalCache,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureTestingWorkspaceProvider(framework: Framework) {
|
||||||
|
framework.impl(
|
||||||
|
WorkspaceFlavourProvider('LOCAL'),
|
||||||
|
TestingWorkspaceLocalProvider,
|
||||||
|
[GlobalState]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { WorkspaceMetadata } from './metadata';
|
||||||
|
|
||||||
|
export interface WorkspaceOpenOptions {
|
||||||
|
metadata: WorkspaceMetadata;
|
||||||
|
isSharedMode?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
|
import type { DocCollection } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { createIdentifier } from '../../../framework';
|
||||||
|
import type { LiveData } from '../../../livedata';
|
||||||
|
import type {
|
||||||
|
AwarenessConnection,
|
||||||
|
BlobStorage,
|
||||||
|
DocServer,
|
||||||
|
DocStorage,
|
||||||
|
} from '../../../sync';
|
||||||
|
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||||
|
import type { Workspace } from '../entities/workspace';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
|
|
||||||
|
export interface WorkspaceEngineProvider {
|
||||||
|
getDocServer(): DocServer | null;
|
||||||
|
getDocStorage(): DocStorage;
|
||||||
|
getLocalBlobStorage(): BlobStorage;
|
||||||
|
getRemoteBlobStorages(): BlobStorage[];
|
||||||
|
getAwarenessConnections(): AwarenessConnection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceFlavourProvider {
|
||||||
|
flavour: WorkspaceFlavour;
|
||||||
|
|
||||||
|
deleteWorkspace(id: string): Promise<void>;
|
||||||
|
|
||||||
|
createWorkspace(
|
||||||
|
initial: (
|
||||||
|
docCollection: DocCollection,
|
||||||
|
blobStorage: BlobStorage
|
||||||
|
) => Promise<void>
|
||||||
|
): Promise<WorkspaceMetadata>;
|
||||||
|
|
||||||
|
workspaces$: LiveData<WorkspaceMetadata[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* means the workspace list is loading. if it's true, the workspace page will show loading spinner.
|
||||||
|
*/
|
||||||
|
isLoading$?: LiveData<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* revalidate the workspace list.
|
||||||
|
*
|
||||||
|
* will be called when user open workspace list, or workspace not found.
|
||||||
|
*/
|
||||||
|
revalidate?: () => void;
|
||||||
|
|
||||||
|
getWorkspaceProfile(id: string): Promise<WorkspaceProfileInfo | undefined>;
|
||||||
|
|
||||||
|
getWorkspaceBlob(id: string, blob: string): Promise<Blob | null>;
|
||||||
|
|
||||||
|
getEngineProvider(workspace: Workspace): WorkspaceEngineProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceFlavourProvider =
|
||||||
|
createIdentifier<WorkspaceFlavourProvider>('WorkspaceFlavourProvider');
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createIdentifier } from '../../../framework';
|
||||||
|
import type { Memento } from '../../../storage';
|
||||||
|
|
||||||
|
export interface WorkspaceLocalState extends Memento {}
|
||||||
|
export interface WorkspaceLocalCache extends Memento {}
|
||||||
|
|
||||||
|
export const WorkspaceLocalState = createIdentifier<WorkspaceLocalState>(
|
||||||
|
'WorkspaceLocalState'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WorkspaceLocalCache = createIdentifier<WorkspaceLocalCache>(
|
||||||
|
'WorkspaceLocalCache'
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Scope } from '../../../framework';
|
||||||
|
import type { WorkspaceOpenOptions } from '../open-options';
|
||||||
|
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||||
|
|
||||||
|
export type { DocCollection } from '@blocksuite/store';
|
||||||
|
|
||||||
|
export class WorkspaceScope extends Scope<{
|
||||||
|
openOptions: WorkspaceOpenOptions;
|
||||||
|
flavourProvider: WorkspaceFlavourProvider;
|
||||||
|
}> {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
|
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||||
|
|
||||||
|
export class WorkspaceDestroyService extends Service {
|
||||||
|
constructor(private readonly providers: WorkspaceFlavourProvider[]) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWorkspace = async (metadata: WorkspaceMetadata) => {
|
||||||
|
const provider = this.providers.find(p => p.flavour === metadata.flavour);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Unknown workspace flavour: ${metadata.flavour}`);
|
||||||
|
}
|
||||||
|
return provider.deleteWorkspace(metadata.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { WorkspaceEngine } from '../entities/engine';
|
||||||
|
import type { WorkspaceService } from './workspace';
|
||||||
|
|
||||||
|
export class WorkspaceEngineService extends Service {
|
||||||
|
private _engine: WorkspaceEngine | null = null;
|
||||||
|
get engine() {
|
||||||
|
if (!this._engine) {
|
||||||
|
this._engine = this.framework.createEntity(WorkspaceEngine, {
|
||||||
|
engineProvider:
|
||||||
|
this.workspaceService.workspace.flavourProvider.getEngineProvider(
|
||||||
|
this.workspaceService.workspace
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this._engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private readonly workspaceService: WorkspaceService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
|
import type { DocCollection } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { Service } from '../../../framework';
|
||||||
|
import type { BlobStorage } from '../../../sync';
|
||||||
|
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||||
|
|
||||||
|
export class WorkspaceFactoryService extends Service {
|
||||||
|
constructor(private readonly providers: WorkspaceFlavourProvider[]) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create workspace
|
||||||
|
* @param flavour workspace flavour
|
||||||
|
* @param initial callback to put initial data to workspace
|
||||||
|
* @returns workspace id
|
||||||
|
*/
|
||||||
|
create = async (
|
||||||
|
flavour: WorkspaceFlavour,
|
||||||
|
initial: (
|
||||||
|
docCollection: DocCollection,
|
||||||
|
blobStorage: BlobStorage
|
||||||
|
) => Promise<void> = () => Promise.resolve()
|
||||||
|
) => {
|
||||||
|
const provider = this.providers.find(x => x.flavour === flavour);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Unknown workspace flavour: ${flavour}`);
|
||||||
|
}
|
||||||
|
const metadata = await provider.createWorkspace(initial);
|
||||||
|
return metadata;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { WorkspaceList } from '../entities/list';
|
||||||
|
|
||||||
|
export class WorkspaceListService extends Service {
|
||||||
|
list = this.framework.createEntity(WorkspaceList);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { ObjectPool } from '../../../utils';
|
||||||
|
import { WorkspaceProfile } from '../entities/profile';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
|
|
||||||
|
export class WorkspaceProfileService extends Service {
|
||||||
|
pool = new ObjectPool<string, WorkspaceProfile>();
|
||||||
|
|
||||||
|
getProfile = (metadata: WorkspaceMetadata): WorkspaceProfile => {
|
||||||
|
const exists = this.pool.get(metadata.id);
|
||||||
|
if (exists) {
|
||||||
|
return exists.obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = this.framework.createEntity(WorkspaceProfile, {
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.pool.put(metadata.id, profile).obj;
|
||||||
|
};
|
||||||
|
}
|
||||||
114
packages/common/infra/src/modules/workspace/services/repo.ts
Normal file
114
packages/common/infra/src/modules/workspace/services/repo.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
|
||||||
|
import { setupEditorFlags } from '../../../atom';
|
||||||
|
import { fixWorkspaceVersion } from '../../../blocksuite';
|
||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { ObjectPool } from '../../../utils';
|
||||||
|
import type { Workspace } from '../entities/workspace';
|
||||||
|
import type { WorkspaceOpenOptions } from '../open-options';
|
||||||
|
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||||
|
import { WorkspaceScope } from '../scopes/workspace';
|
||||||
|
import type { WorkspaceProfileService } from './profile';
|
||||||
|
import { WorkspaceService } from './workspace';
|
||||||
|
|
||||||
|
const logger = new DebugLogger('affine:workspace-repository');
|
||||||
|
|
||||||
|
export class WorkspaceRepositoryService extends Service {
|
||||||
|
constructor(
|
||||||
|
private readonly providers: WorkspaceFlavourProvider[],
|
||||||
|
private readonly profileRepo: WorkspaceProfileService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
pool = new ObjectPool<string, Workspace>({
|
||||||
|
onDelete(workspace) {
|
||||||
|
workspace.scope.dispose();
|
||||||
|
},
|
||||||
|
onDangling(workspace) {
|
||||||
|
return workspace.canGracefulStop;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* open workspace reference by metadata.
|
||||||
|
*
|
||||||
|
* You basically don't need to call this function directly, use the react hook `useWorkspace(metadata)` instead.
|
||||||
|
*
|
||||||
|
* @returns the workspace reference and a release function, don't forget to call release function when you don't
|
||||||
|
* need the workspace anymore.
|
||||||
|
*/
|
||||||
|
open = (
|
||||||
|
options: WorkspaceOpenOptions,
|
||||||
|
customProvider?: WorkspaceFlavourProvider
|
||||||
|
): {
|
||||||
|
workspace: Workspace;
|
||||||
|
dispose: () => void;
|
||||||
|
} => {
|
||||||
|
if (options.isSharedMode) {
|
||||||
|
const workspace = this.instantiate(options, customProvider);
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
dispose: () => {
|
||||||
|
workspace.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exist = this.pool.get(options.metadata.id);
|
||||||
|
if (exist) {
|
||||||
|
return {
|
||||||
|
workspace: exist.obj,
|
||||||
|
dispose: exist.release,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = this.instantiate(options, customProvider);
|
||||||
|
// sync information with workspace list, when workspace's avatar and name changed, information will be updated
|
||||||
|
// this.list.getInformation(metadata).syncWithWorkspace(workspace);
|
||||||
|
|
||||||
|
const ref = this.pool.put(workspace.meta.id, workspace);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace: ref.obj,
|
||||||
|
dispose: ref.release,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
instantiate(
|
||||||
|
openOptions: WorkspaceOpenOptions,
|
||||||
|
customProvider?: WorkspaceFlavourProvider
|
||||||
|
) {
|
||||||
|
logger.info(
|
||||||
|
`open workspace [${openOptions.metadata.flavour}] ${openOptions.metadata.id} `
|
||||||
|
);
|
||||||
|
const provider =
|
||||||
|
customProvider ??
|
||||||
|
this.providers.find(p => p.flavour === openOptions.metadata.flavour);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown workspace flavour: ${openOptions.metadata.flavour}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceScope = this.framework.createScope(WorkspaceScope, {
|
||||||
|
openOptions,
|
||||||
|
flavourProvider: provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspace = workspaceScope.get(WorkspaceService).workspace;
|
||||||
|
|
||||||
|
workspace.engine.setRootDoc(workspace.docCollection.doc);
|
||||||
|
workspace.engine.start();
|
||||||
|
|
||||||
|
// apply compatibility fix
|
||||||
|
fixWorkspaceVersion(workspace.docCollection.doc);
|
||||||
|
|
||||||
|
setupEditorFlags(workspace.docCollection);
|
||||||
|
|
||||||
|
this.profileRepo
|
||||||
|
.getProfile(openOptions.metadata)
|
||||||
|
.syncWithWorkspace(workspace);
|
||||||
|
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
|
import { assertEquals } from '@blocksuite/global/utils';
|
||||||
|
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||||
|
|
||||||
|
import { Service } from '../../../framework';
|
||||||
|
import type { Workspace } from '../entities/workspace';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
|
import type { WorkspaceDestroyService } from './destroy';
|
||||||
|
import type { WorkspaceFactoryService } from './factory';
|
||||||
|
|
||||||
|
export class WorkspaceTransformService extends Service {
|
||||||
|
constructor(
|
||||||
|
private readonly factory: WorkspaceFactoryService,
|
||||||
|
private readonly destroy: WorkspaceDestroyService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* helper function to transform local workspace to cloud workspace
|
||||||
|
*/
|
||||||
|
transformLocalToCloud = async (
|
||||||
|
local: Workspace
|
||||||
|
): Promise<WorkspaceMetadata> => {
|
||||||
|
assertEquals(local.flavour, WorkspaceFlavour.LOCAL);
|
||||||
|
|
||||||
|
await local.engine.waitForDocSynced();
|
||||||
|
|
||||||
|
const newMetadata = await this.factory.create(
|
||||||
|
WorkspaceFlavour.AFFINE_CLOUD,
|
||||||
|
async (ws, bs) => {
|
||||||
|
applyUpdate(ws.doc, encodeStateAsUpdate(local.docCollection.doc));
|
||||||
|
|
||||||
|
for (const subdoc of local.docCollection.doc.getSubdocs()) {
|
||||||
|
for (const newSubdoc of ws.doc.getSubdocs()) {
|
||||||
|
if (newSubdoc.guid === subdoc.guid) {
|
||||||
|
applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobList = await local.engine.blob.list();
|
||||||
|
|
||||||
|
for (const blobKey of blobList) {
|
||||||
|
const blob = await local.engine.blob.get(blobKey);
|
||||||
|
if (blob) {
|
||||||
|
await bs.set(blobKey, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.destroy.deleteWorkspace(local.meta);
|
||||||
|
|
||||||
|
return newMetadata;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { WorkspaceUpgrade } from '../entities/upgrade';
|
||||||
|
|
||||||
|
export class WorkspaceUpgradeService extends Service {
|
||||||
|
upgrade = this.framework.createEntity(WorkspaceUpgrade);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { Workspace } from '../entities/workspace';
|
||||||
|
|
||||||
|
export class WorkspaceService extends Service {
|
||||||
|
_workspace: Workspace | null = null;
|
||||||
|
|
||||||
|
get workspace() {
|
||||||
|
if (!this._workspace) {
|
||||||
|
this._workspace = this.framework.createEntity(Workspace);
|
||||||
|
}
|
||||||
|
return this._workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Service } from '../../../framework';
|
||||||
|
import type { WorkspaceMetadata } from '..';
|
||||||
|
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||||
|
import type { WorkspaceDestroyService } from './destroy';
|
||||||
|
import type { WorkspaceFactoryService } from './factory';
|
||||||
|
import type { WorkspaceListService } from './list';
|
||||||
|
import type { WorkspaceProfileService } from './profile';
|
||||||
|
import type { WorkspaceRepositoryService } from './repo';
|
||||||
|
import type { WorkspaceTransformService } from './transform';
|
||||||
|
|
||||||
|
export class WorkspacesService extends Service {
|
||||||
|
get list() {
|
||||||
|
return this.listService.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly providers: WorkspaceFlavourProvider[],
|
||||||
|
private readonly listService: WorkspaceListService,
|
||||||
|
private readonly profileRepo: WorkspaceProfileService,
|
||||||
|
private readonly transform: WorkspaceTransformService,
|
||||||
|
private readonly workspaceRepo: WorkspaceRepositoryService,
|
||||||
|
private readonly workspaceFactory: WorkspaceFactoryService,
|
||||||
|
private readonly destroy: WorkspaceDestroyService
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get deleteWorkspace() {
|
||||||
|
return this.destroy.deleteWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
get getProfile() {
|
||||||
|
return this.profileRepo.getProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
get transformLocalToCloud() {
|
||||||
|
return this.transform.transformLocalToCloud;
|
||||||
|
}
|
||||||
|
|
||||||
|
get open() {
|
||||||
|
return this.workspaceRepo.open;
|
||||||
|
}
|
||||||
|
|
||||||
|
get create() {
|
||||||
|
return this.workspaceFactory.create;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorkspaceBlob(meta: WorkspaceMetadata, blob: string) {
|
||||||
|
return await this.providers
|
||||||
|
.find(x => x.flavour === meta.flavour)
|
||||||
|
?.getWorkspaceBlob(meta.id, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { map } from 'rxjs';
|
||||||
|
|
||||||
|
import { Store } from '../../../framework';
|
||||||
|
import type { GlobalCache } from '../../storage';
|
||||||
|
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||||
|
|
||||||
|
const WORKSPACE_PROFILE_CACHE_KEY = 'workspace-information:';
|
||||||
|
|
||||||
|
export class WorkspaceProfileCacheStore extends Store {
|
||||||
|
constructor(private readonly cache: GlobalCache) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
watchProfileCache(workspaceId: string) {
|
||||||
|
return this.cache.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceId).pipe(
|
||||||
|
map(data => {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = data as WorkspaceProfileInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatar: info.avatar,
|
||||||
|
name: info.name,
|
||||||
|
isOwner: info.isOwner,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileCache(workspaceId: string, info: WorkspaceProfileInfo) {
|
||||||
|
this.cache.set(WORKSPACE_PROFILE_CACHE_KEY + workspaceId, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
|
import { DocCollection, nanoid } from '@blocksuite/store';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||||
|
|
||||||
|
import { Service } from '../../../framework';
|
||||||
|
import { LiveData } from '../../../livedata';
|
||||||
|
import { wrapMemento } from '../../../storage';
|
||||||
|
import { type BlobStorage, MemoryDocStorage } from '../../../sync';
|
||||||
|
import { MemoryBlobStorage } from '../../../sync/blob/blob';
|
||||||
|
import type { GlobalState } from '../../storage';
|
||||||
|
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||||
|
import type { Workspace } from '../entities/workspace';
|
||||||
|
import { globalBlockSuiteSchema } from '../global-schema';
|
||||||
|
import type { WorkspaceMetadata } from '../metadata';
|
||||||
|
import type {
|
||||||
|
WorkspaceEngineProvider,
|
||||||
|
WorkspaceFlavourProvider,
|
||||||
|
} from '../providers/flavour';
|
||||||
|
|
||||||
|
export class TestingWorkspaceLocalProvider
|
||||||
|
extends Service
|
||||||
|
implements WorkspaceFlavourProvider
|
||||||
|
{
|
||||||
|
flavour: WorkspaceFlavour = WorkspaceFlavour.LOCAL;
|
||||||
|
|
||||||
|
store = wrapMemento(this.globalStore, 'testing/');
|
||||||
|
workspaceListStore = wrapMemento(this.store, 'workspaces/');
|
||||||
|
docStorage = new MemoryDocStorage(wrapMemento(this.store, 'docs/'));
|
||||||
|
|
||||||
|
constructor(private readonly globalStore: GlobalState) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWorkspace(id: string): Promise<void> {
|
||||||
|
const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? [];
|
||||||
|
const newList = list.filter(meta => meta.id !== id);
|
||||||
|
this.workspaceListStore.set('list', newList);
|
||||||
|
}
|
||||||
|
async createWorkspace(
|
||||||
|
initial: (
|
||||||
|
docCollection: DocCollection,
|
||||||
|
blobStorage: BlobStorage
|
||||||
|
) => Promise<void>
|
||||||
|
): Promise<WorkspaceMetadata> {
|
||||||
|
const id = nanoid();
|
||||||
|
const meta = { id, flavour: WorkspaceFlavour.LOCAL };
|
||||||
|
|
||||||
|
const blobStorage = new MemoryBlobStorage(
|
||||||
|
wrapMemento(this.store, id + '/blobs/')
|
||||||
|
);
|
||||||
|
|
||||||
|
const docCollection = new DocCollection({
|
||||||
|
id: id,
|
||||||
|
idGenerator: () => nanoid(),
|
||||||
|
schema: globalBlockSuiteSchema,
|
||||||
|
blobStorages: [
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
crud: blobStorage,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// apply initial state
|
||||||
|
await initial(docCollection, blobStorage);
|
||||||
|
|
||||||
|
// save workspace to storage
|
||||||
|
await this.docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
|
||||||
|
for (const subdocs of docCollection.doc.getSubdocs()) {
|
||||||
|
await this.docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? [];
|
||||||
|
this.workspaceListStore.set('list', [...list, meta]);
|
||||||
|
|
||||||
|
return { id, flavour: WorkspaceFlavour.LOCAL };
|
||||||
|
}
|
||||||
|
workspaces$ = LiveData.from<WorkspaceMetadata[]>(
|
||||||
|
this.workspaceListStore
|
||||||
|
.watch<WorkspaceMetadata[]>('list')
|
||||||
|
.pipe(map(m => m ?? [])),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
async getWorkspaceProfile(
|
||||||
|
id: string
|
||||||
|
): Promise<WorkspaceProfileInfo | undefined> {
|
||||||
|
const data = await this.docStorage.doc.get(id);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bs = new DocCollection({
|
||||||
|
id,
|
||||||
|
schema: globalBlockSuiteSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyUpdate(bs.doc, data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: bs.meta.name,
|
||||||
|
avatar: bs.meta.avatar,
|
||||||
|
isOwner: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
|
||||||
|
return new MemoryBlobStorage(wrapMemento(this.store, id + '/blobs/')).get(
|
||||||
|
blob
|
||||||
|
);
|
||||||
|
}
|
||||||
|
getEngineProvider(workspace: Workspace): WorkspaceEngineProvider {
|
||||||
|
return {
|
||||||
|
getDocStorage: () => {
|
||||||
|
return this.docStorage;
|
||||||
|
},
|
||||||
|
getAwarenessConnections() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getDocServer() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getLocalBlobStorage: () => {
|
||||||
|
return new MemoryBlobStorage(
|
||||||
|
wrapMemento(this.store, workspace.id + '/blobs/')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getRemoteBlobStorages() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
|
||||||
|
|
||||||
import type { ServiceCollection } from '../di';
|
|
||||||
import { createIdentifier } from '../di';
|
|
||||||
import type { PageRecord } from './record';
|
|
||||||
import { PageScope } from './service-scope';
|
|
||||||
|
|
||||||
export const BlockSuitePageContext = createIdentifier<BlockSuiteDoc>(
|
|
||||||
'BlockSuitePageContext'
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PageRecordContext =
|
|
||||||
createIdentifier<PageRecord>('PageRecordContext');
|
|
||||||
|
|
||||||
export function configurePageContext(
|
|
||||||
services: ServiceCollection,
|
|
||||||
blockSuitePage: BlockSuiteDoc,
|
|
||||||
pageRecord: PageRecord
|
|
||||||
) {
|
|
||||||
services
|
|
||||||
.scope(PageScope)
|
|
||||||
.addImpl(PageRecordContext, pageRecord)
|
|
||||||
.addImpl(BlockSuitePageContext, blockSuitePage);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export * from './manager';
|
|
||||||
export * from './page';
|
|
||||||
export * from './record';
|
|
||||||
export * from './record-list';
|
|
||||||
export * from './service-scope';
|
|
||||||
|
|
||||||
import type { ServiceCollection } from '../di';
|
|
||||||
import { ServiceProvider } from '../di';
|
|
||||||
import { CleanupService } from '../lifecycle';
|
|
||||||
import { Workspace, WorkspaceLocalState, WorkspaceScope } from '../workspace';
|
|
||||||
import { BlockSuitePageContext, PageRecordContext } from './context';
|
|
||||||
import { PageManager } from './manager';
|
|
||||||
import { Doc } from './page';
|
|
||||||
import { PageRecordList } from './record-list';
|
|
||||||
import { PageScope } from './service-scope';
|
|
||||||
|
|
||||||
export function configurePageServices(services: ServiceCollection) {
|
|
||||||
services
|
|
||||||
.scope(WorkspaceScope)
|
|
||||||
.add(PageManager, [Workspace, PageRecordList, ServiceProvider])
|
|
||||||
.add(PageRecordList, [Workspace, WorkspaceLocalState]);
|
|
||||||
|
|
||||||
services
|
|
||||||
.scope(PageScope)
|
|
||||||
.add(CleanupService)
|
|
||||||
.add(Doc, [PageRecordContext, BlockSuitePageContext, ServiceProvider]);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { ServiceProvider } from '../di';
|
|
||||||
import { ObjectPool } from '../utils/object-pool';
|
|
||||||
import type { Workspace } from '../workspace';
|
|
||||||
import type { PageRecordList } from '.';
|
|
||||||
import { configurePageContext } from './context';
|
|
||||||
import { Doc } from './page';
|
|
||||||
import { PageScope } from './service-scope';
|
|
||||||
|
|
||||||
export class PageManager {
|
|
||||||
pool = new ObjectPool<string, Doc>({});
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly workspace: Workspace,
|
|
||||||
private readonly pageRecordList: PageRecordList,
|
|
||||||
private readonly serviceProvider: ServiceProvider
|
|
||||||
) {}
|
|
||||||
|
|
||||||
open(pageId: string) {
|
|
||||||
const pageRecord = this.pageRecordList.record$(pageId).value;
|
|
||||||
if (!pageRecord) {
|
|
||||||
throw new Error('Page record not found');
|
|
||||||
}
|
|
||||||
const blockSuitePage = this.workspace.docCollection.getDoc(pageId);
|
|
||||||
if (!blockSuitePage) {
|
|
||||||
throw new Error('Page not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = this.pool.get(pageId);
|
|
||||||
if (exists) {
|
|
||||||
return { page: exists.obj, release: exists.release };
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceCollection = this.serviceProvider.collection
|
|
||||||
// avoid to modify the original service collection
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
configurePageContext(serviceCollection, blockSuitePage, pageRecord);
|
|
||||||
|
|
||||||
const provider = serviceCollection.provider(
|
|
||||||
PageScope,
|
|
||||||
this.serviceProvider
|
|
||||||
);
|
|
||||||
|
|
||||||
const page = provider.get(Doc);
|
|
||||||
|
|
||||||
const { obj, release } = this.pool.put(pageId, page);
|
|
||||||
|
|
||||||
return { page: obj, release };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
|
||||||
|
|
||||||
import type { ServiceProvider } from '../di/core';
|
|
||||||
import type { PageMode, PageRecord } from './record';
|
|
||||||
|
|
||||||
export class Doc {
|
|
||||||
constructor(
|
|
||||||
public readonly record: PageRecord,
|
|
||||||
public readonly blockSuiteDoc: BlockSuiteDoc,
|
|
||||||
public readonly services: ServiceProvider
|
|
||||||
) {}
|
|
||||||
|
|
||||||
get id() {
|
|
||||||
return this.record.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly mete$ = this.record.meta$;
|
|
||||||
readonly mode$ = this.record.mode$;
|
|
||||||
readonly title$ = this.record.title$;
|
|
||||||
|
|
||||||
setMode(mode: PageMode) {
|
|
||||||
this.record.setMode(mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleMode() {
|
|
||||||
this.record.toggleMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { isEqual } from 'lodash-es';
|
|
||||||
import { distinctUntilChanged, map, Observable } from 'rxjs';
|
|
||||||
|
|
||||||
import { LiveData } from '../livedata';
|
|
||||||
import type { Workspace, WorkspaceLocalState } from '../workspace';
|
|
||||||
import { PageRecord } from './record';
|
|
||||||
|
|
||||||
export class PageRecordList {
|
|
||||||
constructor(
|
|
||||||
private readonly workspace: Workspace,
|
|
||||||
private readonly localState: WorkspaceLocalState
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private readonly recordsPool = new Map<string, PageRecord>();
|
|
||||||
|
|
||||||
public readonly records$ = LiveData.from<PageRecord[]>(
|
|
||||||
new Observable<string[]>(subscriber => {
|
|
||||||
const emit = () => {
|
|
||||||
subscriber.next(
|
|
||||||
this.workspace.docCollection.meta.docMetas.map(v => v.id)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
emit();
|
|
||||||
|
|
||||||
const dispose =
|
|
||||||
this.workspace.docCollection.meta.docMetaUpdated.on(emit).dispose;
|
|
||||||
return () => {
|
|
||||||
dispose();
|
|
||||||
};
|
|
||||||
}).pipe(
|
|
||||||
distinctUntilChanged((p, c) => isEqual(p, c)),
|
|
||||||
map(ids =>
|
|
||||||
ids.map(id => {
|
|
||||||
const exists = this.recordsPool.get(id);
|
|
||||||
if (exists) {
|
|
||||||
return exists;
|
|
||||||
}
|
|
||||||
const record = new PageRecord(id, this.workspace, this.localState);
|
|
||||||
this.recordsPool.set(id, record);
|
|
||||||
return record;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
public readonly isReady$ = this.workspace.engine.rootDocState$.map(
|
|
||||||
state => !state.syncing
|
|
||||||
);
|
|
||||||
|
|
||||||
public record$(id: string) {
|
|
||||||
return this.records$.map(record => record.find(record => record.id === id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { DocMeta } from '@blocksuite/store';
|
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
import { distinctUntilChanged, Observable } from 'rxjs';
|
|
||||||
|
|
||||||
import { LiveData } from '../livedata';
|
|
||||||
import type { Workspace, WorkspaceLocalState } from '../workspace';
|
|
||||||
|
|
||||||
export type PageMode = 'edgeless' | 'page';
|
|
||||||
|
|
||||||
export class PageRecord {
|
|
||||||
meta: Partial<DocMeta> | null = null;
|
|
||||||
constructor(
|
|
||||||
public readonly id: string,
|
|
||||||
private readonly workspace: Workspace,
|
|
||||||
private readonly localState: WorkspaceLocalState
|
|
||||||
) {}
|
|
||||||
|
|
||||||
meta$ = LiveData.from<Partial<DocMeta>>(
|
|
||||||
new Observable<Partial<DocMeta>>(subscriber => {
|
|
||||||
const emit = () => {
|
|
||||||
if (this.meta === null) {
|
|
||||||
// getDocMeta is heavy, so we cache the doc meta reference
|
|
||||||
this.meta =
|
|
||||||
this.workspace.docCollection.meta.getDocMeta(this.id) || null;
|
|
||||||
}
|
|
||||||
subscriber.next({ ...this.meta });
|
|
||||||
};
|
|
||||||
|
|
||||||
emit();
|
|
||||||
|
|
||||||
const dispose =
|
|
||||||
this.workspace.docCollection.meta.docMetaUpdated.on(emit).dispose;
|
|
||||||
return () => {
|
|
||||||
dispose();
|
|
||||||
};
|
|
||||||
}).pipe(distinctUntilChanged((p, c) => isEqual(p, c))),
|
|
||||||
{
|
|
||||||
id: this.id,
|
|
||||||
title: '',
|
|
||||||
tags: [],
|
|
||||||
createDate: 0,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
setMeta(meta: Partial<DocMeta>): void {
|
|
||||||
this.workspace.docCollection.setDocMeta(this.id, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
mode$: LiveData<PageMode> = LiveData.from(
|
|
||||||
this.localState.watch<PageMode>(`page:${this.id}:mode`),
|
|
||||||
'page'
|
|
||||||
).map(mode => (mode === 'edgeless' ? 'edgeless' : 'page'));
|
|
||||||
|
|
||||||
setMode(mode: PageMode) {
|
|
||||||
this.localState.set(`page:${this.id}:mode`, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleMode() {
|
|
||||||
this.setMode(this.mode$.value === 'edgeless' ? 'page' : 'edgeless');
|
|
||||||
return this.mode$.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
title$ = this.meta$.map(meta => meta.title ?? '');
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { ServiceScope } from '../di';
|
|
||||||
import { createScope } from '../di';
|
|
||||||
import { WorkspaceScope } from '../workspace';
|
|
||||||
|
|
||||||
export const PageScope: ServiceScope = createScope('page', WorkspaceScope);
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { ServiceCollection } from '../../di';
|
import { MemoryMemento } from '..';
|
||||||
import { GlobalCache, GlobalState, MemoryMemento } from '..';
|
|
||||||
|
|
||||||
describe('memento', () => {
|
describe('memento', () => {
|
||||||
test('memory', () => {
|
test('memory', () => {
|
||||||
@@ -23,18 +22,4 @@ describe('memento', () => {
|
|||||||
memento.set('foo', 'hello');
|
memento.set('foo', 'hello');
|
||||||
expect(subscribed).toEqual('baz');
|
expect(subscribed).toEqual('baz');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('service', () => {
|
|
||||||
const services = new ServiceCollection();
|
|
||||||
|
|
||||||
services
|
|
||||||
.addImpl(GlobalCache, MemoryMemento)
|
|
||||||
.addImpl(GlobalState, MemoryMemento);
|
|
||||||
|
|
||||||
const provider = services.provider();
|
|
||||||
const cache = provider.get(GlobalCache);
|
|
||||||
expect(cache).toBeInstanceOf(MemoryMemento);
|
|
||||||
const state = provider.get(GlobalState);
|
|
||||||
expect(state).toBeInstanceOf(MemoryMemento);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Observable } from 'rxjs';
|
import type { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { createIdentifier } from '../di';
|
|
||||||
import { LiveData } from '../livedata';
|
import { LiveData } from '../livedata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,24 +14,6 @@ export interface Memento {
|
|||||||
keys(): string[];
|
keys(): string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A memento object that stores the entire application state.
|
|
||||||
*
|
|
||||||
* State is persisted, even the application is closed.
|
|
||||||
*/
|
|
||||||
export interface GlobalState extends Memento {}
|
|
||||||
|
|
||||||
export const GlobalState = createIdentifier<GlobalState>('GlobalState');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A memento object that stores the entire application cache.
|
|
||||||
*
|
|
||||||
* Cache may be deleted from time to time, business logic should not rely on cache.
|
|
||||||
*/
|
|
||||||
export interface GlobalCache extends Memento {}
|
|
||||||
|
|
||||||
export const GlobalCache = createIdentifier<GlobalCache>('GlobalCache');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple implementation of Memento. Used for testing.
|
* A simple implementation of Memento. Used for testing.
|
||||||
*/
|
*/
|
||||||
|
|||||||
16
packages/common/infra/src/sync/awareness.ts
Normal file
16
packages/common/infra/src/sync/awareness.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface AwarenessConnection {
|
||||||
|
connect(): void;
|
||||||
|
disconnect(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AwarenessEngine {
|
||||||
|
constructor(public readonly connections: AwarenessConnection[]) {}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.connections.forEach(connection => connection.connect());
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.connections.forEach(connection => connection.disconnect());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ import { DebugLogger } from '@affine/debug';
|
|||||||
import { Slot } from '@blocksuite/global/utils';
|
import { Slot } from '@blocksuite/global/utils';
|
||||||
import { difference } from 'lodash-es';
|
import { difference } from 'lodash-es';
|
||||||
|
|
||||||
import { createIdentifier } from '../../di';
|
import { LiveData } from '../../livedata';
|
||||||
|
import type { Memento } from '../../storage';
|
||||||
import { BlobStorageOverCapacity } from './error';
|
import { BlobStorageOverCapacity } from './error';
|
||||||
|
|
||||||
const logger = new DebugLogger('affine:blob-engine');
|
const logger = new DebugLogger('affine:blob-engine');
|
||||||
@@ -16,12 +17,6 @@ export interface BlobStorage {
|
|||||||
list: () => Promise<string[]>;
|
list: () => Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LocalBlobStorage =
|
|
||||||
createIdentifier<BlobStorage>('LocalBlobStorage');
|
|
||||||
|
|
||||||
export const RemoteBlobStorage =
|
|
||||||
createIdentifier<BlobStorage>('RemoteBlobStorage');
|
|
||||||
|
|
||||||
export interface BlobStatus {
|
export interface BlobStatus {
|
||||||
isStorageOverCapacity: boolean;
|
isStorageOverCapacity: boolean;
|
||||||
}
|
}
|
||||||
@@ -35,27 +30,19 @@ export interface BlobStatus {
|
|||||||
*/
|
*/
|
||||||
export class BlobEngine {
|
export class BlobEngine {
|
||||||
private abort: AbortController | null = null;
|
private abort: AbortController | null = null;
|
||||||
private _status: BlobStatus = { isStorageOverCapacity: false };
|
|
||||||
onStatusChange = new Slot<BlobStatus>();
|
readonly isStorageOverCapacity$ = new LiveData(false);
|
||||||
|
|
||||||
singleBlobSizeLimit: number = 100 * 1024 * 1024;
|
singleBlobSizeLimit: number = 100 * 1024 * 1024;
|
||||||
onAbortLargeBlob = new Slot<Blob>();
|
onAbortLargeBlob = new Slot<Blob>();
|
||||||
|
|
||||||
private set status(s: BlobStatus) {
|
|
||||||
logger.debug('status change', s);
|
|
||||||
this._status = s;
|
|
||||||
this.onStatusChange.emit(s);
|
|
||||||
}
|
|
||||||
get status() {
|
|
||||||
return this._status;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly local: BlobStorage,
|
private readonly local: BlobStorage,
|
||||||
private readonly remotes: BlobStorage[]
|
private readonly remotes: BlobStorage[]
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.abort || this._status.isStorageOverCapacity) {
|
if (this.abort || this.isStorageOverCapacity$.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.abort = new AbortController();
|
this.abort = new AbortController();
|
||||||
@@ -132,9 +119,7 @@ export class BlobEngine {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BlobStorageOverCapacity) {
|
if (err instanceof BlobStorageOverCapacity) {
|
||||||
this.status = {
|
this.isStorageOverCapacity$.value = true;
|
||||||
isStorageOverCapacity: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
logger.error(
|
logger.error(
|
||||||
`error when sync ${key} from [${remote.name}] to [${this.local.name}]`,
|
`error when sync ${key} from [${remote.name}] to [${this.local.name}]`,
|
||||||
@@ -234,3 +219,36 @@ export const EmptyBlobStorage: BlobStorage = {
|
|||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class MemoryBlobStorage implements BlobStorage {
|
||||||
|
name = 'testing';
|
||||||
|
readonly = false;
|
||||||
|
|
||||||
|
constructor(private readonly state: Memento) {}
|
||||||
|
|
||||||
|
get(key: string) {
|
||||||
|
return Promise.resolve(this.state.get<Blob>(key) ?? null);
|
||||||
|
}
|
||||||
|
set(key: string, value: Blob) {
|
||||||
|
this.state.set(key, value);
|
||||||
|
|
||||||
|
const list = this.state.get<Set<string>>('list') ?? new Set<string>();
|
||||||
|
list.add(key);
|
||||||
|
this.state.set('list', list);
|
||||||
|
|
||||||
|
return Promise.resolve(key);
|
||||||
|
}
|
||||||
|
delete(key: string) {
|
||||||
|
this.state.set(key, null);
|
||||||
|
|
||||||
|
const list = this.state.get<Set<string>>('list') ?? new Set<string>();
|
||||||
|
list.delete(key);
|
||||||
|
this.state.set('list', list);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
list() {
|
||||||
|
const list = this.state.get<Set<string>>('list');
|
||||||
|
return Promise.resolve(list ? Array.from(list) : []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
mergeUpdates,
|
mergeUpdates,
|
||||||
} from 'yjs';
|
} from 'yjs';
|
||||||
|
|
||||||
import { AsyncLock } from '../../../../utils';
|
import { AsyncLock } from '../../../utils';
|
||||||
import { DocEngine } from '..';
|
import { DocEngine } from '..';
|
||||||
import type { DocServer } from '../server';
|
import type { DocServer } from '../server';
|
||||||
import { MemoryStorage } from '../storage';
|
import { MemoryStorage } from '../storage';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user