feat(infra): framework

This commit is contained in:
EYHN
2024-04-17 14:12:29 +08:00
parent ab17a05df3
commit 06fda3b62c
467 changed files with 9996 additions and 8697 deletions

View File

@@ -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
View File

@@ -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:

View 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.

View File

@@ -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,

View File

@@ -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!]!

View File

@@ -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(),

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -1,4 +0,0 @@
import type { ServiceVariant } from './types';
export const DEFAULT_SERVICE_VARIANT: ServiceVariant = 'default';
export const ROOT_SCOPE = [];

View File

@@ -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(' -> ');
}

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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('/');
}

View File

@@ -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);
};

View File

@@ -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);
}

View File

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

View File

@@ -0,0 +1,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();
}
}

View File

@@ -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;
}

View 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
};
};

View 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;
}
}

View File

@@ -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 {

View 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';

View 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();
}
}

View File

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

View File

@@ -0,0 +1,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;
};

View 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>
);
};

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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());
}
}

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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);
})
);
})
)
)
);
}

View 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();
}
}

View File

@@ -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));
}
}

View 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 ?? '');
}

View 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);
}

View 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;
}> {}

View 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);
}

View 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 };
}
}

View 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`);
}
}

View File

@@ -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$,
};
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
import { Service } from '../../../framework';
import { GlobalContext } from '../entities/global-context';
export class GlobalContextService extends Service {
globalContext = this.framework.createEntity(GlobalContext);
}

View 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);
}

View File

@@ -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);
}
}

View 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);
};

View File

@@ -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');

View 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();
}
}

View File

@@ -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);
});
});

View File

@@ -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();
}
}

View 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?.());
}
}

View File

@@ -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 });
});
}
}

View 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);
}
}
}

View File

@@ -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
);
}

View 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();
}
}

View 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]
);
}

View File

@@ -0,0 +1,6 @@
import type { WorkspaceMetadata } from './metadata';
export interface WorkspaceOpenOptions {
metadata: WorkspaceMetadata;
isSharedMode?: boolean;
}

View File

@@ -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');

View File

@@ -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'
);

View File

@@ -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;
}> {}

View File

@@ -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);
};
}

View File

@@ -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();
}
}

View File

@@ -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;
};
}

View File

@@ -0,0 +1,6 @@
import { Service } from '../../../framework';
import { WorkspaceList } from '../entities/list';
export class WorkspaceListService extends Service {
list = this.framework.createEntity(WorkspaceList);
}

View File

@@ -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;
};
}

View 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;
}
}

View File

@@ -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;
};
}

View File

@@ -0,0 +1,6 @@
import { Service } from '../../../framework';
import { WorkspaceUpgrade } from '../entities/upgrade';
export class WorkspaceUpgradeService extends Service {
upgrade = this.framework.createEntity(WorkspaceUpgrade);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 [];
},
};
}
}

View File

@@ -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);
}

View File

@@ -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]);
}

View File

@@ -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 };
}
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -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 ?? '');
}

View File

@@ -1,5 +0,0 @@
import type { ServiceScope } from '../di';
import { createScope } from '../di';
import { WorkspaceScope } from '../workspace';
export const PageScope: ServiceScope = createScope('page', WorkspaceScope);

View File

@@ -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);
});
});

View File

@@ -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.
*/

View 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());
}
}

View File

@@ -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) : []);
}
}

View File

@@ -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