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/i18n',
'packages/frontend/native', 'packages/frontend/native',
'packages/frontend/templates', 'packages/frontend/templates',
'packages/frontend/workspace-impl',
'packages/common/debug', 'packages/common/debug',
'packages/common/env', 'packages/common/env',
'packages/common/infra', 'packages/common/infra',

5
.github/labeler.yml vendored
View File

@@ -29,11 +29,6 @@ mod:plugin-cli:
- any-glob-to-any-file: - any-glob-to-any-file:
- 'tools/plugin-cli/**/*' - 'tools/plugin-cli/**/*'
mod:workspace-impl:
- changed-files:
- any-glob-to-any-file:
- 'packages/frontend/workspace-impl/**/*'
mod:i18n: mod:i18n:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:

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. This package should be imported at the very beginning of the entry point.
### `@affine/workspace-impl`
Current we have two workspace plugin:
- `local` for local workspace, which is the default workspace type.
- `affine` for cloud workspace, which is the workspace type for AFFiNE Cloud with OctoBase backend.
#### Design principles #### Design principles
- Each workspace plugin has its state and is isolated from other workspace plugins. - Each workspace plugin has its state and is isolated from other workspace plugins.

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 * @deprecated
*/ */
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
name: 'sharePage', name: 'sharePage',
deprecationReason: 'renamed to publicPage', deprecationReason: 'renamed to publishPage',
}) })
async deprecatedSharePage( async deprecatedSharePage(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,

View File

@@ -219,7 +219,7 @@ type Mutation {
sendVerifyEmail(callbackUrl: String!): Boolean! sendVerifyEmail(callbackUrl: String!): Boolean!
setBlob(blob: Upload!, workspaceId: String!): String! setBlob(blob: Upload!, workspaceId: String!): String!
setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean! setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean!
sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publicPage") sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publishPage")
signIn(email: String!, password: String!): UserType! signIn(email: String!, password: String!): UserType!
signUp(email: String!, name: String!, password: String!): UserType! signUp(email: String!, name: String!, password: String!): UserType!
updateProfile(input: UpdateUserInput!): UserType! updateProfile(input: UpdateUserInput!): UserType!
@@ -530,6 +530,9 @@ type WorkspaceType {
"""is Public workspace""" """is Public workspace"""
public: Boolean! public: Boolean!
"""Get public page of a workspace by page id."""
publicPage(pageId: String!): WorkspacePage
"""Public pages of a workspace""" """Public pages of a workspace"""
publicPages: [WorkspacePage!]! publicPages: [WorkspacePage!]!

View File

@@ -18,7 +18,6 @@ export const runtimeFlagsSchema = z.object({
enablePreloading: z.boolean(), enablePreloading: z.boolean(),
enableNewSettingModal: z.boolean(), enableNewSettingModal: z.boolean(),
enableNewSettingUnstableApi: z.boolean(), enableNewSettingUnstableApi: z.boolean(),
enableSQLiteProvider: z.boolean(),
enableCloud: z.boolean(), enableCloud: z.boolean(),
enableCaptcha: z.boolean(), enableCaptcha: z.boolean(),
enableEnhanceShareMode: z.boolean(), enableEnhanceShareMode: z.boolean(),

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 { stableHash } from '../../utils/stable-hash';
import { DEFAULT_SERVICE_VARIANT } from './consts'; import type { Component } from './components/component';
import { DEFAULT_VARIANT } from './consts';
import type { import type {
ServiceIdentifier, ComponentVariant,
ServiceIdentifierValue, Identifier,
ServiceVariant, IdentifierValue,
Type, Type,
} from './types'; } from './types';
/** /**
* create a ServiceIdentifier. * create a Identifier.
* *
* ServiceIdentifier is used to identify a certain type of service. With the identifier, you can reference one or more services * Identifier is used to identify a certain type of service. With the identifier, you can reference one or more services
* without knowing the specific implementation, thereby achieving * without knowing the specific implementation, thereby achieving
* [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control). * [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control).
* *
@@ -38,10 +39,10 @@ import type {
* } * }
* *
* // register the implementation to the identifier * // register the implementation to the identifier
* services.addImpl(Storage, LocalStorage); * framework.impl(Storage, LocalStorage);
* *
* // get the implementation from the identifier * // get the implementation from the identifier
* const storage = services.provider().get(Storage); * const storage = framework.provider().get(Storage);
* storage.set('foo', 'bar'); * storage.set('foo', 'bar');
* ``` * ```
* *
@@ -63,13 +64,13 @@ import type {
* const LocalStorage = Storage('local'); * const LocalStorage = Storage('local');
* const SessionStorage = Storage('session'); * const SessionStorage = Storage('session');
* *
* services.addImpl(LocalStorage, LocalStorageImpl); * framework.impl(LocalStorage, LocalStorageImpl);
* services.addImpl(SessionStorage, SessionStorageImpl); * framework.impl(SessionStorage, SessionStorageImpl);
* *
* // get the implementation from the identifier * // get the implementation from the identifier
* const localStorage = services.provider().get(LocalStorage); * const localStorage = framework.provider().get(LocalStorage);
* const sessionStorage = services.provider().get(SessionStorage); * const sessionStorage = framework.provider().get(SessionStorage);
* const storage = services.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl } * const storage = framework.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl }
* ``` * ```
* *
* @param name unique name of the identifier. * @param name unique name of the identifier.
@@ -77,10 +78,10 @@ import type {
*/ */
export function createIdentifier<T>( export function createIdentifier<T>(
name: string, name: string,
variant: ServiceVariant = DEFAULT_SERVICE_VARIANT variant: ComponentVariant = DEFAULT_VARIANT
): ServiceIdentifier<T> & ((variant: ServiceVariant) => ServiceIdentifier<T>) { ): Identifier<T> & ((variant: ComponentVariant) => Identifier<T>) {
return Object.assign( return Object.assign(
(variant: ServiceVariant) => { (variant: ComponentVariant) => {
return createIdentifier<T>(name, variant); return createIdentifier<T>(name, variant);
}, },
{ {
@@ -96,15 +97,15 @@ export function createIdentifier<T>(
* *
* @internal * @internal
*/ */
export function createIdentifierFromConstructor<T>( export function createIdentifierFromConstructor<T extends Component>(
target: Type<T> target: Type<T>
): ServiceIdentifier<T> { ): Identifier<T> {
return createIdentifier<T>(`${target.name}${stableHash(target)}`); return createIdentifier<T>(`${target.name}${stableHash(target)}`);
} }
export function parseIdentifier(input: any): ServiceIdentifierValue { export function parseIdentifier(input: any): IdentifierValue {
if (input.identifierName) { if (input.identifierName) {
return input as ServiceIdentifierValue; return input as IdentifierValue;
} else if (typeof input === 'function' && input.name) { } else if (typeof input === 'function' && input.name) {
return createIdentifierFromConstructor(input); return createIdentifierFromConstructor(input);
} else { } else {

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 './atom';
export * from './blocksuite'; export * from './blocksuite';
export * from './command'; export * from './command';
export * from './di'; export * from './framework';
export * from './initialization'; export * from './initialization';
export * from './lifecycle';
export * from './livedata'; export * from './livedata';
export * from './page'; export * from './modules/doc';
export * from './modules/global-context';
export * from './modules/lifecycle';
export * from './modules/storage';
export * from './modules/workspace';
export * from './storage'; export * from './storage';
export * from './sync';
export * from './utils'; export * from './utils';
export * from './workspace';
import type { ServiceCollection } from './di'; import type { Framework } from './framework';
import { CleanupService } from './lifecycle'; import { configureDocModule } from './modules/doc';
import { configurePageServices } from './page'; import { configureGlobalContextModule } from './modules/global-context';
import { GlobalCache, GlobalState, MemoryMemento } from './storage'; import { configureLifecycleModule } from './modules/lifecycle';
import { import {
configureTestingWorkspaceServices, configureGlobalStorageModule,
configureWorkspaceServices, configureTestingGlobalStorage,
} from './workspace'; } from './modules/storage';
import {
configureTestingWorkspaceProvider,
configureWorkspaceModule,
} from './modules/workspace';
export function configureInfraServices(services: ServiceCollection) { export function configureInfraModules(framework: Framework) {
services.add(CleanupService); configureWorkspaceModule(framework);
configureWorkspaceServices(services); configureDocModule(framework);
configurePageServices(services); configureGlobalStorageModule(framework);
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
} }
export function configureTestingInfraServices(services: ServiceCollection) { export function configureTestingInfraModules(framework: Framework) {
configureTestingWorkspaceServices(services); configureTestingGlobalStorage(framework);
services.override(GlobalCache, MemoryMemento); configureTestingWorkspaceProvider(framework);
services.override(GlobalState, MemoryMemento);
} }

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); inner$.next(4);
expect(flatten$.value).toEqual([4, 3]); expect(flatten$.value).toEqual([4, 3]);
} }
{
const wrapped$ = new LiveData([] as LiveData<number>[]);
const flatten$ = wrapped$.flat();
expect(flatten$.value).toEqual([]);
}
}); });
test('computed', () => { test('computed', () => {

View File

@@ -4,10 +4,43 @@ import { type OperatorFunction, Subject } from 'rxjs';
const logger = new DebugLogger('effect'); const logger = new DebugLogger('effect');
export interface Effect<T> { export type Effect<T> = (T | undefined extends T // hack to detect if T is unknown
(value: T): void; ? () => void
} : (value: T) => void) & {
// unsubscribe effect, all ongoing effects will be cancelled.
unsubscribe: () => void;
};
/**
* Create an effect.
*
* `effect( op1, op2, op3, ... )`
*
* You can think of an effect as a pipeline. When the effect is called, argument will be sent to the pipeline,
* and the operators in the pipeline can be triggered.
*
*
*
* @example
* ```ts
* const loadUser = effect(
* switchMap((id: number) =>
* from(fetchUser(id)).pipe(
* mapInto(user$),
* catchErrorInto(error$),
* onStart(() => isLoading$.next(true)),
* onComplete(() => isLoading$.next(false))
* )
* )
* );
*
* // emit value to effect
* loadUser(1);
*
* // unsubscribe effect, will stop all ongoing processes
* loadUser.unsubscribe();
* ```
*/
export function effect<T, A>(op1: OperatorFunction<T, A>): Effect<T>; export function effect<T, A>(op1: OperatorFunction<T, A>): Effect<T>;
export function effect<T, A, B>( export function effect<T, A, B>(
op1: OperatorFunction<T, A>, op1: OperatorFunction<T, A>,
@@ -42,23 +75,47 @@ export function effect<T, A, B, C, D, E, F>(
export function effect(...args: any[]) { export function effect(...args: any[]) {
const subject$ = new Subject<any>(); const subject$ = new Subject<any>();
const effectLocation = environment.isDebug
? `(${new Error().stack?.split('\n')[2].trim()})`
: '';
class EffectError extends Unreachable {
constructor(message: string, value?: any) {
logger.error(`effect ${effectLocation} ${message}`, value);
super(
`effect ${effectLocation} ${message}` +
` ${value ? (value instanceof Error ? value.stack ?? value.message : value + '') : ''}`
);
}
}
// eslint-disable-next-line prefer-spread // eslint-disable-next-line prefer-spread
subject$.pipe.apply(subject$, args as any).subscribe({ const subscription = subject$.pipe.apply(subject$, args as any).subscribe({
next(value) { next(value) {
logger.error('effect should not emit value', value); const error = new EffectError('should not emit value', value);
throw new Unreachable('effect should not emit value'); setImmediate(() => {
throw error;
});
}, },
complete() { complete() {
logger.error('effect unexpected complete'); const error = new EffectError('effect unexpected complete');
throw new Unreachable('effect unexpected complete'); setImmediate(() => {
throw error;
});
}, },
error(error) { error(error) {
logger.error('effect uncatched error', error); const effectError = new EffectError('effect uncaught error', error);
throw new Unreachable('effect uncatched error'); setImmediate(() => {
throw effectError;
});
}, },
}); });
return ((value: unknown) => { const fn = (value: unknown) => {
subject$.next(value); subject$.next(value);
}) as never; };
fn.unsubscribe = () => subscription.unsubscribe();
return fn as never;
} }

View File

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

View File

@@ -428,6 +428,9 @@ export class LiveData<T = unknown>
if (v instanceof LiveData) { if (v instanceof LiveData) {
return (v as LiveData<any>).flat(); return (v as LiveData<any>).flat();
} else if (Array.isArray(v)) { } else if (Array.isArray(v)) {
if (v.length === 0) {
return of([]);
}
return combineLatest( return combineLatest(
v.map(v => { v.map(v => {
if (v instanceof LiveData) { if (v instanceof LiveData) {
@@ -446,6 +449,29 @@ export class LiveData<T = unknown>
) as any; ) as any;
} }
waitFor(predicate: (v: T) => unknown, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const subscription = this.subscribe(v => {
if (predicate(v)) {
resolve(v as any);
setImmediate(() => {
subscription.unsubscribe();
});
}
});
signal?.addEventListener('abort', reason => {
subscription.unsubscribe();
reject(reason);
});
});
}
waitForNonNull(signal?: AbortSignal) {
return this.waitFor(v => v !== null && v !== undefined, signal) as Promise<
NonNullable<T>
>;
}
reactSubscribe = (cb: () => void) => { reactSubscribe = (cb: () => void) => {
if (this.isPoisoned) { if (this.isPoisoned) {
throw this.poisonedError; throw this.poisonedError;

View File

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

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 { describe, expect, test } from 'vitest';
import { ServiceCollection } from '../../di'; import { MemoryMemento } from '..';
import { GlobalCache, GlobalState, MemoryMemento } from '..';
describe('memento', () => { describe('memento', () => {
test('memory', () => { test('memory', () => {
@@ -23,18 +22,4 @@ describe('memento', () => {
memento.set('foo', 'hello'); memento.set('foo', 'hello');
expect(subscribed).toEqual('baz'); expect(subscribed).toEqual('baz');
}); });
test('service', () => {
const services = new ServiceCollection();
services
.addImpl(GlobalCache, MemoryMemento)
.addImpl(GlobalState, MemoryMemento);
const provider = services.provider();
const cache = provider.get(GlobalCache);
expect(cache).toBeInstanceOf(MemoryMemento);
const state = provider.get(GlobalState);
expect(state).toBeInstanceOf(MemoryMemento);
});
}); });

View File

@@ -1,6 +1,5 @@
import type { Observable } from 'rxjs'; import type { Observable } from 'rxjs';
import { createIdentifier } from '../di';
import { LiveData } from '../livedata'; import { LiveData } from '../livedata';
/** /**
@@ -15,24 +14,6 @@ export interface Memento {
keys(): string[]; keys(): string[];
} }
/**
* A memento object that stores the entire application state.
*
* State is persisted, even the application is closed.
*/
export interface GlobalState extends Memento {}
export const GlobalState = createIdentifier<GlobalState>('GlobalState');
/**
* A memento object that stores the entire application cache.
*
* Cache may be deleted from time to time, business logic should not rely on cache.
*/
export interface GlobalCache extends Memento {}
export const GlobalCache = createIdentifier<GlobalCache>('GlobalCache');
/** /**
* A simple implementation of Memento. Used for testing. * A simple implementation of Memento. Used for testing.
*/ */

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 { Slot } from '@blocksuite/global/utils';
import { difference } from 'lodash-es'; import { difference } from 'lodash-es';
import { createIdentifier } from '../../di'; import { LiveData } from '../../livedata';
import type { Memento } from '../../storage';
import { BlobStorageOverCapacity } from './error'; import { BlobStorageOverCapacity } from './error';
const logger = new DebugLogger('affine:blob-engine'); const logger = new DebugLogger('affine:blob-engine');
@@ -16,12 +17,6 @@ export interface BlobStorage {
list: () => Promise<string[]>; list: () => Promise<string[]>;
} }
export const LocalBlobStorage =
createIdentifier<BlobStorage>('LocalBlobStorage');
export const RemoteBlobStorage =
createIdentifier<BlobStorage>('RemoteBlobStorage');
export interface BlobStatus { export interface BlobStatus {
isStorageOverCapacity: boolean; isStorageOverCapacity: boolean;
} }
@@ -35,27 +30,19 @@ export interface BlobStatus {
*/ */
export class BlobEngine { export class BlobEngine {
private abort: AbortController | null = null; private abort: AbortController | null = null;
private _status: BlobStatus = { isStorageOverCapacity: false };
onStatusChange = new Slot<BlobStatus>(); readonly isStorageOverCapacity$ = new LiveData(false);
singleBlobSizeLimit: number = 100 * 1024 * 1024; singleBlobSizeLimit: number = 100 * 1024 * 1024;
onAbortLargeBlob = new Slot<Blob>(); onAbortLargeBlob = new Slot<Blob>();
private set status(s: BlobStatus) {
logger.debug('status change', s);
this._status = s;
this.onStatusChange.emit(s);
}
get status() {
return this._status;
}
constructor( constructor(
private readonly local: BlobStorage, private readonly local: BlobStorage,
private readonly remotes: BlobStorage[] private readonly remotes: BlobStorage[]
) {} ) {}
start() { start() {
if (this.abort || this._status.isStorageOverCapacity) { if (this.abort || this.isStorageOverCapacity$.value) {
return; return;
} }
this.abort = new AbortController(); this.abort = new AbortController();
@@ -132,9 +119,7 @@ export class BlobEngine {
} }
} catch (err) { } catch (err) {
if (err instanceof BlobStorageOverCapacity) { if (err instanceof BlobStorageOverCapacity) {
this.status = { this.isStorageOverCapacity$.value = true;
isStorageOverCapacity: true,
};
} }
logger.error( logger.error(
`error when sync ${key} from [${remote.name}] to [${this.local.name}]`, `error when sync ${key} from [${remote.name}] to [${this.local.name}]`,
@@ -234,3 +219,36 @@ export const EmptyBlobStorage: BlobStorage = {
return []; return [];
}, },
}; };
export class MemoryBlobStorage implements BlobStorage {
name = 'testing';
readonly = false;
constructor(private readonly state: Memento) {}
get(key: string) {
return Promise.resolve(this.state.get<Blob>(key) ?? null);
}
set(key: string, value: Blob) {
this.state.set(key, value);
const list = this.state.get<Set<string>>('list') ?? new Set<string>();
list.add(key);
this.state.set('list', list);
return Promise.resolve(key);
}
delete(key: string) {
this.state.set(key, null);
const list = this.state.get<Set<string>>('list') ?? new Set<string>();
list.delete(key);
this.state.set('list', list);
return Promise.resolve();
}
list() {
const list = this.state.get<Set<string>>('list');
return Promise.resolve(list ? Array.from(list) : []);
}
}

View File

@@ -8,7 +8,7 @@ import {
mergeUpdates, mergeUpdates,
} from 'yjs'; } from 'yjs';
import { AsyncLock } from '../../../../utils'; import { AsyncLock } from '../../../utils';
import { DocEngine } from '..'; import { DocEngine } from '..';
import type { DocServer } from '../server'; import type { DocServer } from '../server';
import { MemoryStorage } from '../storage'; import { MemoryStorage } from '../storage';

Some files were not shown because too many files have changed in this diff Show More