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/native',
|
||||
'packages/frontend/templates',
|
||||
'packages/frontend/workspace-impl',
|
||||
'packages/common/debug',
|
||||
'packages/common/env',
|
||||
'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:
|
||||
- 'tools/plugin-cli/**/*'
|
||||
|
||||
mod:workspace-impl:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/frontend/workspace-impl/**/*'
|
||||
|
||||
mod:i18n:
|
||||
- changed-files:
|
||||
- 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.
|
||||
|
||||
### `@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
|
||||
|
||||
- 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
|
||||
*/
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'sharePage',
|
||||
deprecationReason: 'renamed to publicPage',
|
||||
deprecationReason: 'renamed to publishPage',
|
||||
})
|
||||
async deprecatedSharePage(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
|
||||
@@ -219,7 +219,7 @@ type Mutation {
|
||||
sendVerifyEmail(callbackUrl: String!): Boolean!
|
||||
setBlob(blob: Upload!, workspaceId: String!): String!
|
||||
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!
|
||||
signUp(email: String!, name: String!, password: String!): UserType!
|
||||
updateProfile(input: UpdateUserInput!): UserType!
|
||||
@@ -530,6 +530,9 @@ type WorkspaceType {
|
||||
"""is Public workspace"""
|
||||
public: Boolean!
|
||||
|
||||
"""Get public page of a workspace by page id."""
|
||||
publicPage(pageId: String!): WorkspacePage
|
||||
|
||||
"""Public pages of a workspace"""
|
||||
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(),
|
||||
enableNewSettingModal: z.boolean(),
|
||||
enableNewSettingUnstableApi: z.boolean(),
|
||||
enableSQLiteProvider: z.boolean(),
|
||||
enableCloud: z.boolean(),
|
||||
enableCaptcha: 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 { DEFAULT_SERVICE_VARIANT } from './consts';
|
||||
import type { Component } from './components/component';
|
||||
import { DEFAULT_VARIANT } from './consts';
|
||||
import type {
|
||||
ServiceIdentifier,
|
||||
ServiceIdentifierValue,
|
||||
ServiceVariant,
|
||||
ComponentVariant,
|
||||
Identifier,
|
||||
IdentifierValue,
|
||||
Type,
|
||||
} 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
|
||||
* [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control).
|
||||
*
|
||||
@@ -38,10 +39,10 @@ import type {
|
||||
* }
|
||||
*
|
||||
* // register the implementation to the identifier
|
||||
* services.addImpl(Storage, LocalStorage);
|
||||
* framework.impl(Storage, LocalStorage);
|
||||
*
|
||||
* // get the implementation from the identifier
|
||||
* const storage = services.provider().get(Storage);
|
||||
* const storage = framework.provider().get(Storage);
|
||||
* storage.set('foo', 'bar');
|
||||
* ```
|
||||
*
|
||||
@@ -63,13 +64,13 @@ import type {
|
||||
* const LocalStorage = Storage('local');
|
||||
* const SessionStorage = Storage('session');
|
||||
*
|
||||
* services.addImpl(LocalStorage, LocalStorageImpl);
|
||||
* services.addImpl(SessionStorage, SessionStorageImpl);
|
||||
* framework.impl(LocalStorage, LocalStorageImpl);
|
||||
* framework.impl(SessionStorage, SessionStorageImpl);
|
||||
*
|
||||
* // get the implementation from the identifier
|
||||
* const localStorage = services.provider().get(LocalStorage);
|
||||
* const sessionStorage = services.provider().get(SessionStorage);
|
||||
* const storage = services.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
|
||||
* const localStorage = framework.provider().get(LocalStorage);
|
||||
* const sessionStorage = framework.provider().get(SessionStorage);
|
||||
* const storage = framework.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
|
||||
* ```
|
||||
*
|
||||
* @param name unique name of the identifier.
|
||||
@@ -77,10 +78,10 @@ import type {
|
||||
*/
|
||||
export function createIdentifier<T>(
|
||||
name: string,
|
||||
variant: ServiceVariant = DEFAULT_SERVICE_VARIANT
|
||||
): ServiceIdentifier<T> & ((variant: ServiceVariant) => ServiceIdentifier<T>) {
|
||||
variant: ComponentVariant = DEFAULT_VARIANT
|
||||
): Identifier<T> & ((variant: ComponentVariant) => Identifier<T>) {
|
||||
return Object.assign(
|
||||
(variant: ServiceVariant) => {
|
||||
(variant: ComponentVariant) => {
|
||||
return createIdentifier<T>(name, variant);
|
||||
},
|
||||
{
|
||||
@@ -96,15 +97,15 @@ export function createIdentifier<T>(
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function createIdentifierFromConstructor<T>(
|
||||
export function createIdentifierFromConstructor<T extends Component>(
|
||||
target: Type<T>
|
||||
): ServiceIdentifier<T> {
|
||||
): Identifier<T> {
|
||||
return createIdentifier<T>(`${target.name}${stableHash(target)}`);
|
||||
}
|
||||
|
||||
export function parseIdentifier(input: any): ServiceIdentifierValue {
|
||||
export function parseIdentifier(input: any): IdentifierValue {
|
||||
if (input.identifierName) {
|
||||
return input as ServiceIdentifierValue;
|
||||
return input as IdentifierValue;
|
||||
} else if (typeof input === 'function' && input.name) {
|
||||
return createIdentifierFromConstructor(input);
|
||||
} 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 './blocksuite';
|
||||
export * from './command';
|
||||
export * from './di';
|
||||
export * from './framework';
|
||||
export * from './initialization';
|
||||
export * from './lifecycle';
|
||||
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 './sync';
|
||||
export * from './utils';
|
||||
export * from './workspace';
|
||||
|
||||
import type { ServiceCollection } from './di';
|
||||
import { CleanupService } from './lifecycle';
|
||||
import { configurePageServices } from './page';
|
||||
import { GlobalCache, GlobalState, MemoryMemento } from './storage';
|
||||
import type { Framework } from './framework';
|
||||
import { configureDocModule } from './modules/doc';
|
||||
import { configureGlobalContextModule } from './modules/global-context';
|
||||
import { configureLifecycleModule } from './modules/lifecycle';
|
||||
import {
|
||||
configureTestingWorkspaceServices,
|
||||
configureWorkspaceServices,
|
||||
} from './workspace';
|
||||
configureGlobalStorageModule,
|
||||
configureTestingGlobalStorage,
|
||||
} from './modules/storage';
|
||||
import {
|
||||
configureTestingWorkspaceProvider,
|
||||
configureWorkspaceModule,
|
||||
} from './modules/workspace';
|
||||
|
||||
export function configureInfraServices(services: ServiceCollection) {
|
||||
services.add(CleanupService);
|
||||
configureWorkspaceServices(services);
|
||||
configurePageServices(services);
|
||||
export function configureInfraModules(framework: Framework) {
|
||||
configureWorkspaceModule(framework);
|
||||
configureDocModule(framework);
|
||||
configureGlobalStorageModule(framework);
|
||||
configureGlobalContextModule(framework);
|
||||
configureLifecycleModule(framework);
|
||||
}
|
||||
|
||||
export function configureTestingInfraServices(services: ServiceCollection) {
|
||||
configureTestingWorkspaceServices(services);
|
||||
services.override(GlobalCache, MemoryMemento);
|
||||
services.override(GlobalState, MemoryMemento);
|
||||
export function configureTestingInfraModules(framework: Framework) {
|
||||
configureTestingGlobalStorage(framework);
|
||||
configureTestingWorkspaceProvider(framework);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
expect(flatten$.value).toEqual([4, 3]);
|
||||
}
|
||||
|
||||
{
|
||||
const wrapped$ = new LiveData([] as LiveData<number>[]);
|
||||
const flatten$ = wrapped$.flat();
|
||||
|
||||
expect(flatten$.value).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('computed', () => {
|
||||
|
||||
@@ -4,10 +4,43 @@ import { type OperatorFunction, Subject } from 'rxjs';
|
||||
|
||||
const logger = new DebugLogger('effect');
|
||||
|
||||
export interface Effect<T> {
|
||||
(value: T): void;
|
||||
}
|
||||
export type Effect<T> = (T | undefined extends T // hack to detect if T is unknown
|
||||
? () => void
|
||||
: (value: T) => void) & {
|
||||
// unsubscribe effect, all ongoing effects will be cancelled.
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an effect.
|
||||
*
|
||||
* `effect( op1, op2, op3, ... )`
|
||||
*
|
||||
* You can think of an effect as a pipeline. When the effect is called, argument will be sent to the pipeline,
|
||||
* and the operators in the pipeline can be triggered.
|
||||
*
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const loadUser = effect(
|
||||
* switchMap((id: number) =>
|
||||
* from(fetchUser(id)).pipe(
|
||||
* mapInto(user$),
|
||||
* catchErrorInto(error$),
|
||||
* onStart(() => isLoading$.next(true)),
|
||||
* onComplete(() => isLoading$.next(false))
|
||||
* )
|
||||
* )
|
||||
* );
|
||||
*
|
||||
* // emit value to effect
|
||||
* loadUser(1);
|
||||
*
|
||||
* // unsubscribe effect, will stop all ongoing processes
|
||||
* loadUser.unsubscribe();
|
||||
* ```
|
||||
*/
|
||||
export function effect<T, A>(op1: OperatorFunction<T, A>): Effect<T>;
|
||||
export function effect<T, A, B>(
|
||||
op1: OperatorFunction<T, A>,
|
||||
@@ -42,23 +75,47 @@ export function effect<T, A, B, C, D, E, F>(
|
||||
export function effect(...args: 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
|
||||
subject$.pipe.apply(subject$, args as any).subscribe({
|
||||
const subscription = subject$.pipe.apply(subject$, args as any).subscribe({
|
||||
next(value) {
|
||||
logger.error('effect should not emit value', value);
|
||||
throw new Unreachable('effect should not emit value');
|
||||
const error = new EffectError('should not emit value', value);
|
||||
setImmediate(() => {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
complete() {
|
||||
logger.error('effect unexpected complete');
|
||||
throw new Unreachable('effect unexpected complete');
|
||||
const error = new EffectError('effect unexpected complete');
|
||||
setImmediate(() => {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
error(error) {
|
||||
logger.error('effect uncatched error', error);
|
||||
throw new Unreachable('effect uncatched error');
|
||||
const effectError = new EffectError('effect uncaught error', error);
|
||||
setImmediate(() => {
|
||||
throw effectError;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ((value: unknown) => {
|
||||
const fn = (value: unknown) => {
|
||||
subject$.next(value);
|
||||
}) as never;
|
||||
};
|
||||
|
||||
fn.unsubscribe = () => subscription.unsubscribe();
|
||||
|
||||
return fn as never;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export { type Effect, effect } from './effect';
|
||||
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';
|
||||
|
||||
@@ -428,6 +428,9 @@ export class LiveData<T = unknown>
|
||||
if (v instanceof LiveData) {
|
||||
return (v as LiveData<any>).flat();
|
||||
} else if (Array.isArray(v)) {
|
||||
if (v.length === 0) {
|
||||
return of([]);
|
||||
}
|
||||
return combineLatest(
|
||||
v.map(v => {
|
||||
if (v instanceof LiveData) {
|
||||
@@ -446,6 +449,29 @@ export class LiveData<T = unknown>
|
||||
) 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) => {
|
||||
if (this.isPoisoned) {
|
||||
throw this.poisonedError;
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import {
|
||||
catchError,
|
||||
connect,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
merge,
|
||||
mergeMap,
|
||||
Observable,
|
||||
type ObservableInput,
|
||||
type ObservedValueOf,
|
||||
of,
|
||||
type OperatorFunction,
|
||||
pipe,
|
||||
retry,
|
||||
switchMap,
|
||||
throwError,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
|
||||
import type { LiveData } from './livedata';
|
||||
|
||||
/**
|
||||
* An operator that maps the value to the `LiveData`.
|
||||
*/
|
||||
export function mapInto<T>(l$: LiveData<T>) {
|
||||
return pipe(
|
||||
mergeMap((value: T) => {
|
||||
@@ -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(
|
||||
onComplete(() => l$.next(null)),
|
||||
catchError((error: any) => {
|
||||
l$.next(error);
|
||||
cb?.(error);
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An operator that calls the callback when the observable starts.
|
||||
*/
|
||||
export function onStart<T>(cb: () => void): OperatorFunction<T, T> {
|
||||
return observable$ =>
|
||||
new Observable(subscribe => {
|
||||
@@ -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> {
|
||||
return observable$ =>
|
||||
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 { ServiceCollection } from '../../di';
|
||||
import { GlobalCache, GlobalState, MemoryMemento } from '..';
|
||||
import { MemoryMemento } from '..';
|
||||
|
||||
describe('memento', () => {
|
||||
test('memory', () => {
|
||||
@@ -23,18 +22,4 @@ describe('memento', () => {
|
||||
memento.set('foo', 'hello');
|
||||
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 { createIdentifier } from '../di';
|
||||
import { LiveData } from '../livedata';
|
||||
|
||||
/**
|
||||
@@ -15,24 +14,6 @@ export interface Memento {
|
||||
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.
|
||||
*/
|
||||
|
||||
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 { difference } from 'lodash-es';
|
||||
|
||||
import { createIdentifier } from '../../di';
|
||||
import { LiveData } from '../../livedata';
|
||||
import type { Memento } from '../../storage';
|
||||
import { BlobStorageOverCapacity } from './error';
|
||||
|
||||
const logger = new DebugLogger('affine:blob-engine');
|
||||
@@ -16,12 +17,6 @@ export interface BlobStorage {
|
||||
list: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
export const LocalBlobStorage =
|
||||
createIdentifier<BlobStorage>('LocalBlobStorage');
|
||||
|
||||
export const RemoteBlobStorage =
|
||||
createIdentifier<BlobStorage>('RemoteBlobStorage');
|
||||
|
||||
export interface BlobStatus {
|
||||
isStorageOverCapacity: boolean;
|
||||
}
|
||||
@@ -35,27 +30,19 @@ export interface BlobStatus {
|
||||
*/
|
||||
export class BlobEngine {
|
||||
private abort: AbortController | null = null;
|
||||
private _status: BlobStatus = { isStorageOverCapacity: false };
|
||||
onStatusChange = new Slot<BlobStatus>();
|
||||
|
||||
readonly isStorageOverCapacity$ = new LiveData(false);
|
||||
|
||||
singleBlobSizeLimit: number = 100 * 1024 * 1024;
|
||||
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(
|
||||
private readonly local: BlobStorage,
|
||||
private readonly remotes: BlobStorage[]
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (this.abort || this._status.isStorageOverCapacity) {
|
||||
if (this.abort || this.isStorageOverCapacity$.value) {
|
||||
return;
|
||||
}
|
||||
this.abort = new AbortController();
|
||||
@@ -132,9 +119,7 @@ export class BlobEngine {
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BlobStorageOverCapacity) {
|
||||
this.status = {
|
||||
isStorageOverCapacity: true,
|
||||
};
|
||||
this.isStorageOverCapacity$.value = true;
|
||||
}
|
||||
logger.error(
|
||||
`error when sync ${key} from [${remote.name}] to [${this.local.name}]`,
|
||||
@@ -234,3 +219,36 @@ export const EmptyBlobStorage: BlobStorage = {
|
||||
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,
|
||||
} from 'yjs';
|
||||
|
||||
import { AsyncLock } from '../../../../utils';
|
||||
import { AsyncLock } from '../../../utils';
|
||||
import { DocEngine } from '..';
|
||||
import type { DocServer } from '../server';
|
||||
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