@textui/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +11 -0
- package/dist/app/app.d.ts +200 -0
- package/dist/app/app.d.ts.map +1 -0
- package/dist/app/app.js +980 -0
- package/dist/core/animation.d.ts +34 -0
- package/dist/core/animation.d.ts.map +1 -0
- package/dist/core/animation.js +125 -0
- package/dist/core/clipboard.d.ts +29 -0
- package/dist/core/clipboard.d.ts.map +1 -0
- package/dist/core/clipboard.js +30 -0
- package/dist/core/commands.d.ts +42 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +146 -0
- package/dist/core/components.d.ts +27 -0
- package/dist/core/components.d.ts.map +1 -0
- package/dist/core/components.js +78 -0
- package/dist/core/events.d.ts +23 -0
- package/dist/core/events.d.ts.map +1 -0
- package/dist/core/events.js +77 -0
- package/dist/core/focus.d.ts +69 -0
- package/dist/core/focus.d.ts.map +1 -0
- package/dist/core/focus.js +336 -0
- package/dist/core/i18n.d.ts +27 -0
- package/dist/core/i18n.d.ts.map +1 -0
- package/dist/core/i18n.js +88 -0
- package/dist/core/keybindings.d.ts +53 -0
- package/dist/core/keybindings.d.ts.map +1 -0
- package/dist/core/keybindings.js +163 -0
- package/dist/core/layers.d.ts +28 -0
- package/dist/core/layers.d.ts.map +1 -0
- package/dist/core/layers.js +84 -0
- package/dist/core/manifest.d.ts +22 -0
- package/dist/core/manifest.d.ts.map +1 -0
- package/dist/core/manifest.js +85 -0
- package/dist/core/navigation.d.ts +47 -0
- package/dist/core/navigation.d.ts.map +1 -0
- package/dist/core/navigation.js +110 -0
- package/dist/core/resources.d.ts +115 -0
- package/dist/core/resources.d.ts.map +1 -0
- package/dist/core/resources.js +321 -0
- package/dist/core/services.d.ts +21 -0
- package/dist/core/services.d.ts.map +1 -0
- package/dist/core/services.js +63 -0
- package/dist/core/store.d.ts +60 -0
- package/dist/core/store.d.ts.map +1 -0
- package/dist/core/store.js +593 -0
- package/dist/core/surfaces.d.ts +81 -0
- package/dist/core/surfaces.d.ts.map +1 -0
- package/dist/core/surfaces.js +237 -0
- package/dist/core/syntax.d.ts +49 -0
- package/dist/core/syntax.d.ts.map +1 -0
- package/dist/core/syntax.js +172 -0
- package/dist/core/when.d.ts +15 -0
- package/dist/core/when.d.ts.map +1 -0
- package/dist/core/when.js +224 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/jsx/factory.d.ts +27 -0
- package/dist/jsx/factory.d.ts.map +1 -0
- package/dist/jsx/factory.js +102 -0
- package/dist/jsx/intrinsics.d.ts +96 -0
- package/dist/jsx/intrinsics.d.ts.map +1 -0
- package/dist/jsx/intrinsics.js +1 -0
- package/dist/jsx/jsx-dev-runtime.d.ts +13 -0
- package/dist/jsx/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/jsx/jsx-dev-runtime.js +12 -0
- package/dist/jsx/jsx-runtime.d.ts +42 -0
- package/dist/jsx/jsx-runtime.d.ts.map +1 -0
- package/dist/jsx/jsx-runtime.js +16 -0
- package/dist/render/buffer.d.ts +77 -0
- package/dist/render/buffer.d.ts.map +1 -0
- package/dist/render/buffer.js +275 -0
- package/dist/render/color.d.ts +35 -0
- package/dist/render/color.d.ts.map +1 -0
- package/dist/render/color.js +160 -0
- package/dist/render/diff.d.ts +37 -0
- package/dist/render/diff.d.ts.map +1 -0
- package/dist/render/diff.js +66 -0
- package/dist/render/layout.d.ts +72 -0
- package/dist/render/layout.d.ts.map +1 -0
- package/dist/render/layout.js +633 -0
- package/dist/render/static.d.ts +63 -0
- package/dist/render/static.d.ts.map +1 -0
- package/dist/render/static.js +209 -0
- package/dist/runtime/bindings.d.ts +46 -0
- package/dist/runtime/bindings.d.ts.map +1 -0
- package/dist/runtime/bindings.js +116 -0
- package/dist/runtime/hooks.d.ts +284 -0
- package/dist/runtime/hooks.d.ts.map +1 -0
- package/dist/runtime/hooks.js +846 -0
- package/dist/runtime/instance.d.ts +106 -0
- package/dist/runtime/instance.d.ts.map +1 -0
- package/dist/runtime/instance.js +181 -0
- package/dist/runtime/paint.d.ts +27 -0
- package/dist/runtime/paint.d.ts.map +1 -0
- package/dist/runtime/paint.js +567 -0
- package/dist/runtime/reconcile.d.ts +23 -0
- package/dist/runtime/reconcile.d.ts.map +1 -0
- package/dist/runtime/reconcile.js +260 -0
- package/dist/runtime/runtime.d.ts +42 -0
- package/dist/runtime/runtime.d.ts.map +1 -0
- package/dist/runtime/runtime.js +1 -0
- package/dist/runtime/style.d.ts +55 -0
- package/dist/runtime/style.d.ts.map +1 -0
- package/dist/runtime/style.js +143 -0
- package/dist/themes/borders.d.ts +10 -0
- package/dist/themes/borders.d.ts.map +1 -0
- package/dist/themes/borders.js +88 -0
- package/dist/themes/builtin.d.ts +31 -0
- package/dist/themes/builtin.d.ts.map +1 -0
- package/dist/themes/builtin.js +291 -0
- package/dist/themes/glyphs.d.ts +15 -0
- package/dist/themes/glyphs.d.ts.map +1 -0
- package/dist/themes/glyphs.js +97 -0
- package/dist/themes/index.d.ts +5 -0
- package/dist/themes/index.d.ts.map +1 -0
- package/dist/themes/index.js +4 -0
- package/dist/themes/registry.d.ts +20 -0
- package/dist/themes/registry.d.ts.map +1 -0
- package/dist/themes/registry.js +204 -0
- package/dist/types/adapter.d.ts +38 -0
- package/dist/types/adapter.d.ts.map +1 -0
- package/dist/types/adapter.js +1 -0
- package/dist/types/animation.d.ts +31 -0
- package/dist/types/animation.d.ts.map +1 -0
- package/dist/types/animation.js +1 -0
- package/dist/types/app.d.ts +148 -0
- package/dist/types/app.d.ts.map +1 -0
- package/dist/types/app.js +1 -0
- package/dist/types/async.d.ts +30 -0
- package/dist/types/async.d.ts.map +1 -0
- package/dist/types/async.js +1 -0
- package/dist/types/capabilities.d.ts +44 -0
- package/dist/types/capabilities.d.ts.map +1 -0
- package/dist/types/capabilities.js +33 -0
- package/dist/types/cells.d.ts +72 -0
- package/dist/types/cells.d.ts.map +1 -0
- package/dist/types/cells.js +10 -0
- package/dist/types/command.d.ts +127 -0
- package/dist/types/command.d.ts.map +1 -0
- package/dist/types/command.js +1 -0
- package/dist/types/component-registry.d.ts +77 -0
- package/dist/types/component-registry.d.ts.map +1 -0
- package/dist/types/component-registry.js +1 -0
- package/dist/types/disposable.d.ts +8 -0
- package/dist/types/disposable.d.ts.map +1 -0
- package/dist/types/disposable.js +1 -0
- package/dist/types/focus.d.ts +72 -0
- package/dist/types/focus.d.ts.map +1 -0
- package/dist/types/focus.js +1 -0
- package/dist/types/geometry.d.ts +28 -0
- package/dist/types/geometry.d.ts.map +1 -0
- package/dist/types/geometry.js +14 -0
- package/dist/types/graph.d.ts +129 -0
- package/dist/types/graph.d.ts.map +1 -0
- package/dist/types/graph.js +31 -0
- package/dist/types/i18n.d.ts +23 -0
- package/dist/types/i18n.d.ts.map +1 -0
- package/dist/types/i18n.js +1 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +31 -0
- package/dist/types/input.d.ts +65 -0
- package/dist/types/input.d.ts.map +1 -0
- package/dist/types/input.js +1 -0
- package/dist/types/keybinding.d.ts +49 -0
- package/dist/types/keybinding.d.ts.map +1 -0
- package/dist/types/keybinding.js +1 -0
- package/dist/types/layer.d.ts +54 -0
- package/dist/types/layer.d.ts.map +1 -0
- package/dist/types/layer.js +1 -0
- package/dist/types/manifest.d.ts +114 -0
- package/dist/types/manifest.d.ts.map +1 -0
- package/dist/types/manifest.js +1 -0
- package/dist/types/markdown.d.ts +58 -0
- package/dist/types/markdown.d.ts.map +1 -0
- package/dist/types/markdown.js +1 -0
- package/dist/types/navigation.d.ts +40 -0
- package/dist/types/navigation.d.ts.map +1 -0
- package/dist/types/navigation.js +1 -0
- package/dist/types/render.d.ts +72 -0
- package/dist/types/render.d.ts.map +1 -0
- package/dist/types/render.js +1 -0
- package/dist/types/resource.d.ts +170 -0
- package/dist/types/resource.d.ts.map +1 -0
- package/dist/types/resource.js +1 -0
- package/dist/types/services.d.ts +24 -0
- package/dist/types/services.d.ts.map +1 -0
- package/dist/types/services.js +3 -0
- package/dist/types/shell.d.ts +32 -0
- package/dist/types/shell.d.ts.map +1 -0
- package/dist/types/shell.js +1 -0
- package/dist/types/store.d.ts +124 -0
- package/dist/types/store.d.ts.map +1 -0
- package/dist/types/store.js +1 -0
- package/dist/types/stream.d.ts +18 -0
- package/dist/types/stream.d.ts.map +1 -0
- package/dist/types/stream.js +1 -0
- package/dist/types/style.d.ts +166 -0
- package/dist/types/style.d.ts.map +1 -0
- package/dist/types/style.js +1 -0
- package/dist/types/surface.d.ts +97 -0
- package/dist/types/surface.d.ts.map +1 -0
- package/dist/types/surface.js +1 -0
- package/dist/types/syntax.d.ts +68 -0
- package/dist/types/syntax.d.ts.map +1 -0
- package/dist/types/syntax.js +5 -0
- package/dist/types/terminal.d.ts +52 -0
- package/dist/types/terminal.d.ts.map +1 -0
- package/dist/types/terminal.js +1 -0
- package/dist/types/theme.d.ts +117 -0
- package/dist/types/theme.d.ts.map +1 -0
- package/dist/types/theme.js +1 -0
- package/dist/types/when.d.ts +19 -0
- package/dist/types/when.d.ts.map +1 -0
- package/dist/types/when.js +1 -0
- package/dist/ui/primitives.d.ts +56 -0
- package/dist/ui/primitives.d.ts.map +1 -0
- package/dist/ui/primitives.js +108 -0
- package/dist/ui/screen.d.ts +23 -0
- package/dist/ui/screen.d.ts.map +1 -0
- package/dist/ui/screen.js +18 -0
- package/dist/util/disposable.d.ts +6 -0
- package/dist/util/disposable.d.ts.map +1 -0
- package/dist/util/disposable.js +49 -0
- package/dist/util/markdown.d.ts +24 -0
- package/dist/util/markdown.d.ts.map +1 -0
- package/dist/util/markdown.js +220 -0
- package/dist/util/paths.d.ts +47 -0
- package/dist/util/paths.d.ts.map +1 -0
- package/dist/util/paths.js +134 -0
- package/dist/util/stream.d.ts +32 -0
- package/dist/util/stream.d.ts.map +1 -0
- package/dist/util/stream.js +206 -0
- package/dist/util/text.d.ts +65 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +419 -0
- package/package.json +70 -0
- package/src/adapters/index.ts +11 -0
- package/src/app/app.ts +1096 -0
- package/src/core/animation.ts +144 -0
- package/src/core/clipboard.ts +40 -0
- package/src/core/commands.ts +169 -0
- package/src/core/components.ts +91 -0
- package/src/core/events.ts +95 -0
- package/src/core/focus.ts +358 -0
- package/src/core/i18n.ts +107 -0
- package/src/core/keybindings.ts +184 -0
- package/src/core/layers.ts +94 -0
- package/src/core/manifest.ts +84 -0
- package/src/core/navigation.ts +135 -0
- package/src/core/resources.ts +362 -0
- package/src/core/services.ts +69 -0
- package/src/core/store.ts +640 -0
- package/src/core/surfaces.ts +292 -0
- package/src/core/syntax.ts +200 -0
- package/src/core/when.ts +238 -0
- package/src/index.ts +76 -0
- package/src/jsx/factory.ts +124 -0
- package/src/jsx/intrinsics.ts +99 -0
- package/src/jsx/jsx-dev-runtime.ts +23 -0
- package/src/jsx/jsx-runtime.ts +76 -0
- package/src/render/buffer.ts +318 -0
- package/src/render/color.ts +180 -0
- package/src/render/diff.ts +99 -0
- package/src/render/layout.ts +764 -0
- package/src/render/static.ts +290 -0
- package/src/runtime/bindings.ts +150 -0
- package/src/runtime/hooks.ts +1057 -0
- package/src/runtime/instance.ts +284 -0
- package/src/runtime/paint.ts +737 -0
- package/src/runtime/reconcile.ts +335 -0
- package/src/runtime/runtime.ts +47 -0
- package/src/runtime/style.ts +205 -0
- package/src/themes/borders.ts +95 -0
- package/src/themes/builtin.ts +301 -0
- package/src/themes/glyphs.ts +101 -0
- package/src/themes/index.ts +4 -0
- package/src/themes/registry.ts +224 -0
- package/src/types/adapter.ts +41 -0
- package/src/types/animation.ts +34 -0
- package/src/types/app.ts +149 -0
- package/src/types/async.ts +35 -0
- package/src/types/capabilities.ts +79 -0
- package/src/types/cells.ts +79 -0
- package/src/types/command.ts +139 -0
- package/src/types/component-registry.ts +82 -0
- package/src/types/disposable.ts +8 -0
- package/src/types/focus.ts +77 -0
- package/src/types/geometry.ts +50 -0
- package/src/types/graph.ts +174 -0
- package/src/types/i18n.ts +25 -0
- package/src/types/index.ts +31 -0
- package/src/types/input.ts +81 -0
- package/src/types/keybinding.ts +51 -0
- package/src/types/layer.ts +46 -0
- package/src/types/manifest.ts +101 -0
- package/src/types/markdown.ts +47 -0
- package/src/types/navigation.ts +42 -0
- package/src/types/render.ts +86 -0
- package/src/types/resource.ts +185 -0
- package/src/types/services.ts +28 -0
- package/src/types/shell.ts +30 -0
- package/src/types/store.ts +148 -0
- package/src/types/stream.ts +24 -0
- package/src/types/style.ts +205 -0
- package/src/types/surface.ts +119 -0
- package/src/types/syntax.ts +93 -0
- package/src/types/terminal.ts +58 -0
- package/src/types/theme.ts +121 -0
- package/src/types/when.ts +21 -0
- package/src/ui/primitives.ts +118 -0
- package/src/ui/screen.ts +41 -0
- package/src/util/disposable.ts +49 -0
- package/src/util/markdown.ts +225 -0
- package/src/util/paths.ts +138 -0
- package/src/util/stream.ts +213 -0
- package/src/util/text.ts +428 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ServiceContainer, ServiceKey } from '../types/services.js';
|
|
2
|
+
import type { Disposable } from '../types/disposable.js';
|
|
3
|
+
import { toDisposable } from '../util/disposable.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A typed lookup table, not a dependency-injection container. No lifecycles,
|
|
7
|
+
* no scoping rules, no auto-wiring - a child falls back to its parent and that
|
|
8
|
+
* is the whole of it.
|
|
9
|
+
*/
|
|
10
|
+
export class Services implements ServiceContainer {
|
|
11
|
+
private values = new Map<string, unknown>();
|
|
12
|
+
private factories = new Map<string, () => unknown>();
|
|
13
|
+
|
|
14
|
+
constructor(private parent: ServiceContainer | null = null) {}
|
|
15
|
+
|
|
16
|
+
provide<T>(key: ServiceKey<T>, value: T): Disposable {
|
|
17
|
+
this.values.set(key.id, value);
|
|
18
|
+
return toDisposable(() => {
|
|
19
|
+
// Only if nothing has replaced it since.
|
|
20
|
+
//
|
|
21
|
+
// Two providers for one key overlap whenever one mounts before the other
|
|
22
|
+
// unmounts - which is the ordinary case for anything provided by the
|
|
23
|
+
// *focused* control, since focus arrives at the new one before it leaves
|
|
24
|
+
// the old. Deleting unconditionally took the live provider out along
|
|
25
|
+
// with the stale one, and left the key answering `undefined` while
|
|
26
|
+
// something was still very much providing it.
|
|
27
|
+
if (this.values.get(key.id) === value) this.values.delete(key.id);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
provideLazy<T>(key: ServiceKey<T>, factory: () => T): Disposable {
|
|
32
|
+
this.factories.set(key.id, factory);
|
|
33
|
+
return toDisposable(() => {
|
|
34
|
+
this.factories.delete(key.id);
|
|
35
|
+
this.values.delete(key.id);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get<T>(key: ServiceKey<T>): T | undefined {
|
|
40
|
+
if (this.values.has(key.id)) return this.values.get(key.id) as T;
|
|
41
|
+
const factory = this.factories.get(key.id);
|
|
42
|
+
if (factory) {
|
|
43
|
+
const value = factory() as T;
|
|
44
|
+
this.values.set(key.id, value);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
return this.parent?.get(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
require<T>(key: ServiceKey<T>): T {
|
|
51
|
+
const value = this.get(key);
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
throw new Error(`[textui] no service provided for "${key.id}"`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
has<T>(key: ServiceKey<T>): boolean {
|
|
59
|
+
return this.values.has(key.id) || this.factories.has(key.id) || (this.parent?.has(key) ?? false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
child(): ServiceContainer {
|
|
63
|
+
return new Services(this);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createServices(parent?: ServiceContainer): Services {
|
|
68
|
+
return new Services(parent ?? null);
|
|
69
|
+
}
|
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangeRecord, CollectionOps, ComputedDefinition, DataProviderDefinition,
|
|
3
|
+
PathSchema, PersistenceAdapter, ReactiveStore, ScopeName, SubscribeOptions,
|
|
4
|
+
} from '../types/store.js';
|
|
5
|
+
import type { BindingPath } from '../types/graph.js';
|
|
6
|
+
import type { Disposable } from '../types/disposable.js';
|
|
7
|
+
import { toDisposable } from '../util/disposable.js';
|
|
8
|
+
import {
|
|
9
|
+
ancestorKeys, hasWildcard, isDescendantKey, matchKey, pathKey, PathError,
|
|
10
|
+
} from '../util/paths.js';
|
|
11
|
+
|
|
12
|
+
interface Subscription {
|
|
13
|
+
key: string;
|
|
14
|
+
subtree: boolean;
|
|
15
|
+
wildcard: boolean;
|
|
16
|
+
fn: (value: unknown, change: ChangeRecord) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ComputedEntry {
|
|
20
|
+
key: string;
|
|
21
|
+
deps: string[];
|
|
22
|
+
compute(): unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ProviderEntry {
|
|
26
|
+
def: DataProviderDefinition;
|
|
27
|
+
loaded: boolean;
|
|
28
|
+
loading: Promise<void> | null;
|
|
29
|
+
unloadTimer: ReturnType<typeof setTimeout> | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The store is one tree, and it is the only place state lives. A component
|
|
34
|
+
* that keeps a copy in its own state has created a second answer to a question
|
|
35
|
+
* the store already answers.
|
|
36
|
+
*/
|
|
37
|
+
export class Store implements ReactiveStore {
|
|
38
|
+
private root: Record<string, unknown> = {};
|
|
39
|
+
private subs = new Set<Subscription>();
|
|
40
|
+
private computedByKey = new Map<string, ComputedEntry>();
|
|
41
|
+
private computedDeps = new Map<string, Set<string>>();
|
|
42
|
+
private providers = new Map<string, ProviderEntry>();
|
|
43
|
+
private schemas = new Map<string, PathSchema>();
|
|
44
|
+
private persistence = new Set<PersistenceAdapter>();
|
|
45
|
+
private persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
46
|
+
|
|
47
|
+
private batchDepth = 0;
|
|
48
|
+
private pending = new Map<string, ChangeRecord>();
|
|
49
|
+
private disposed = false;
|
|
50
|
+
|
|
51
|
+
/** Thrown-away errors from listeners would hide bugs; collect and report. */
|
|
52
|
+
onError: (err: unknown, context: string) => void = (err, context) => {
|
|
53
|
+
console.error(`[textui/store] ${context}`, err);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------- reading
|
|
57
|
+
|
|
58
|
+
get<T = unknown>(path: BindingPath): T | undefined {
|
|
59
|
+
const key = pathKey(path);
|
|
60
|
+
this.touchProvider(key);
|
|
61
|
+
return this.readKey(key) as T | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
read<T>(path: BindingPath, fallback: T): T {
|
|
65
|
+
const v = this.get<T>(path);
|
|
66
|
+
return v === undefined ? fallback : v;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
has(path: BindingPath): boolean {
|
|
70
|
+
return this.readKey(pathKey(path)) !== undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private readKey(key: string): unknown {
|
|
74
|
+
if (key === '') return this.root;
|
|
75
|
+
const segs = key.split('/');
|
|
76
|
+
let node: unknown = this.root;
|
|
77
|
+
for (const seg of segs) {
|
|
78
|
+
if (node === null || node === undefined) return undefined;
|
|
79
|
+
if (Array.isArray(node)) {
|
|
80
|
+
const i = Number(seg);
|
|
81
|
+
if (!Number.isInteger(i)) return undefined;
|
|
82
|
+
node = node[i];
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (typeof node !== 'object') return undefined;
|
|
86
|
+
node = (node as Record<string, unknown>)[seg];
|
|
87
|
+
}
|
|
88
|
+
return node;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------- writing
|
|
92
|
+
|
|
93
|
+
set(path: BindingPath, value: unknown): void {
|
|
94
|
+
if (hasWildcard(path)) {
|
|
95
|
+
throw new PathError('wildcards are legal in subscriptions only', path);
|
|
96
|
+
}
|
|
97
|
+
const key = pathKey(path);
|
|
98
|
+
this.writeKey(key, value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
update<T = unknown>(path: BindingPath, fn: (current: T | undefined) => T): void {
|
|
102
|
+
this.set(path, fn(this.get<T>(path)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
patch(path: BindingPath, partial: Record<string, unknown>): void {
|
|
106
|
+
const current = this.get<Record<string, unknown>>(path);
|
|
107
|
+
const base = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
|
|
108
|
+
this.set(path, { ...base, ...partial });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
patchMany(entries: Record<string, unknown>): void {
|
|
112
|
+
this.batch(() => {
|
|
113
|
+
for (const [path, value] of Object.entries(entries)) {
|
|
114
|
+
this.set(path as BindingPath, value);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
delete(path: BindingPath): void {
|
|
120
|
+
const key = pathKey(path);
|
|
121
|
+
const segs = key.split('/');
|
|
122
|
+
const last = segs.pop() as string;
|
|
123
|
+
const parent = segs.length === 0 ? this.root : this.readKey(segs.join('/'));
|
|
124
|
+
if (parent === null || typeof parent !== 'object') return;
|
|
125
|
+
const previous = (parent as Record<string, unknown>)[last];
|
|
126
|
+
if (previous === undefined && !(last in (parent as object))) return;
|
|
127
|
+
|
|
128
|
+
if (Array.isArray(parent)) parent.splice(Number(last), 1);
|
|
129
|
+
else delete (parent as Record<string, unknown>)[last];
|
|
130
|
+
|
|
131
|
+
this.enqueue({ path: `$/${key}` as BindingPath, value: undefined, previous });
|
|
132
|
+
this.flushIfIdle();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private writeKey(key: string, value: unknown): void {
|
|
136
|
+
if (this.disposed) return;
|
|
137
|
+
if (key === '') throw new PathError('cannot write the store root', '$/');
|
|
138
|
+
|
|
139
|
+
const schema = this.schemas.get(key);
|
|
140
|
+
if (schema) {
|
|
141
|
+
const problem = schema.validate(value);
|
|
142
|
+
if (problem) throw new PathError(`schema rejected value (${problem})`, `$/${key}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const segs = key.split('/');
|
|
146
|
+
const last = segs.pop() as string;
|
|
147
|
+
|
|
148
|
+
let node: Record<string, unknown> = this.root;
|
|
149
|
+
for (const seg of segs) {
|
|
150
|
+
let next = node[seg];
|
|
151
|
+
if (next === null || typeof next !== 'object') {
|
|
152
|
+
next = {};
|
|
153
|
+
node[seg] = next;
|
|
154
|
+
}
|
|
155
|
+
node = next as Record<string, unknown>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const previous = node[last];
|
|
159
|
+
if (previous === value) return;
|
|
160
|
+
node[last] = value;
|
|
161
|
+
|
|
162
|
+
this.enqueue({ path: `$/${key}` as BindingPath, value, previous });
|
|
163
|
+
this.flushIfIdle();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --------------------------------------------------------------- batching
|
|
167
|
+
|
|
168
|
+
batch<T>(fn: () => T): T {
|
|
169
|
+
this.batchDepth++;
|
|
170
|
+
try {
|
|
171
|
+
return fn();
|
|
172
|
+
} finally {
|
|
173
|
+
this.batchDepth--;
|
|
174
|
+
this.flushIfIdle();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private enqueue(change: ChangeRecord): void {
|
|
179
|
+
const key = pathKey(change.path);
|
|
180
|
+
const existing = this.pending.get(key);
|
|
181
|
+
// Keep the oldest `previous` so a batch reports one net change.
|
|
182
|
+
this.pending.set(key, existing ? { ...change, previous: existing.previous } : change);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private flushIfIdle(): void {
|
|
186
|
+
if (this.batchDepth > 0 || this.pending.size === 0) return;
|
|
187
|
+
|
|
188
|
+
// Recomputing may enqueue more; drain until quiet, with a runaway guard.
|
|
189
|
+
let rounds = 0;
|
|
190
|
+
while (this.pending.size > 0) {
|
|
191
|
+
if (++rounds > 50) {
|
|
192
|
+
this.onError(new Error('computed paths did not settle after 50 rounds'), 'flush');
|
|
193
|
+
this.pending.clear();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const changes = [...this.pending.values()];
|
|
197
|
+
this.pending.clear();
|
|
198
|
+
|
|
199
|
+
this.batchDepth++;
|
|
200
|
+
try {
|
|
201
|
+
for (const change of changes) this.recomputeDependents(pathKey(change.path));
|
|
202
|
+
} finally {
|
|
203
|
+
this.batchDepth--;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const change of changes) this.notify(change);
|
|
207
|
+
for (const change of changes) this.schedulePersist(pathKey(change.path));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private notify(change: ChangeRecord): void {
|
|
212
|
+
const key = pathKey(change.path);
|
|
213
|
+
for (const sub of [...this.subs]) {
|
|
214
|
+
if (!this.subMatches(sub, key)) continue;
|
|
215
|
+
try {
|
|
216
|
+
sub.fn(this.readKey(sub.wildcard ? key : sub.key), change);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
this.onError(err, `subscriber of $/${sub.key}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private subMatches(sub: Subscription, changedKey: string): boolean {
|
|
224
|
+
if (sub.wildcard) return matchKey(changedKey, sub.key, sub.subtree);
|
|
225
|
+
if (sub.key === changedKey) return true;
|
|
226
|
+
// A write below an exact subscription still changes that object's contents.
|
|
227
|
+
if (isDescendantKey(changedKey, sub.key)) return true;
|
|
228
|
+
// A write above it may have replaced the subtree the subscriber reads.
|
|
229
|
+
if (isDescendantKey(sub.key, changedKey)) return true;
|
|
230
|
+
return sub.subtree && ancestorKeys(changedKey).includes(sub.key);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------- subscriptions
|
|
234
|
+
|
|
235
|
+
subscribe(
|
|
236
|
+
path: BindingPath,
|
|
237
|
+
fn: (value: unknown, change: ChangeRecord) => void,
|
|
238
|
+
options: SubscribeOptions = {},
|
|
239
|
+
): Disposable {
|
|
240
|
+
const key = pathKey(path);
|
|
241
|
+
const sub: Subscription = {
|
|
242
|
+
key,
|
|
243
|
+
subtree: options.subtree ?? false,
|
|
244
|
+
wildcard: hasWildcard(key),
|
|
245
|
+
fn,
|
|
246
|
+
};
|
|
247
|
+
this.subs.add(sub);
|
|
248
|
+
this.touchProvider(key);
|
|
249
|
+
|
|
250
|
+
if (options.immediate) {
|
|
251
|
+
try {
|
|
252
|
+
const value = this.readKey(key);
|
|
253
|
+
fn(value, { path, value, previous: undefined });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
this.onError(err, `immediate subscriber of ${path}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return toDisposable(() => {
|
|
260
|
+
this.subs.delete(sub);
|
|
261
|
+
this.maybeUnloadProvider(key);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
subscriberCount(scope: ScopeName | string): number {
|
|
266
|
+
let n = 0;
|
|
267
|
+
for (const sub of this.subs) {
|
|
268
|
+
if (sub.key === scope || sub.key.startsWith(scope + '/')) n++;
|
|
269
|
+
}
|
|
270
|
+
return n;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
clearScope(scope: ScopeName | string): void {
|
|
274
|
+
const key = pathKey(scope.startsWith('$/') ? scope : `$/${scope}`);
|
|
275
|
+
const previous = this.readKey(key);
|
|
276
|
+
if (previous === undefined) return;
|
|
277
|
+
const segs = key.split('/');
|
|
278
|
+
const last = segs.pop() as string;
|
|
279
|
+
const parent = segs.length === 0 ? this.root : this.readKey(segs.join('/'));
|
|
280
|
+
if (parent && typeof parent === 'object') {
|
|
281
|
+
delete (parent as Record<string, unknown>)[last];
|
|
282
|
+
}
|
|
283
|
+
this.enqueue({ path: `$/${key}` as BindingPath, value: undefined, previous });
|
|
284
|
+
this.flushIfIdle();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// -------------------------------------------------------------- computed
|
|
288
|
+
|
|
289
|
+
computed<T = unknown>(path: BindingPath, def: ComputedDefinition<T>): Disposable {
|
|
290
|
+
const key = pathKey(path);
|
|
291
|
+
const deps = def.from.map((p) => pathKey(p));
|
|
292
|
+
const select = typeof def.select === 'string'
|
|
293
|
+
? compileSelect(def.select, def.from)
|
|
294
|
+
: def.select;
|
|
295
|
+
|
|
296
|
+
const entry: ComputedEntry = {
|
|
297
|
+
key,
|
|
298
|
+
deps,
|
|
299
|
+
compute: () => {
|
|
300
|
+
const values: Record<string, unknown> = {};
|
|
301
|
+
for (let i = 0; i < def.from.length; i++) {
|
|
302
|
+
const p = def.from[i] as string;
|
|
303
|
+
values[p] = this.readKey(deps[i] as string);
|
|
304
|
+
}
|
|
305
|
+
return select(values);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
this.computedByKey.set(key, entry);
|
|
310
|
+
for (const dep of deps) {
|
|
311
|
+
let set = this.computedDeps.get(dep);
|
|
312
|
+
if (!set) {
|
|
313
|
+
set = new Set();
|
|
314
|
+
this.computedDeps.set(dep, set);
|
|
315
|
+
}
|
|
316
|
+
set.add(key);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.recompute(entry);
|
|
320
|
+
|
|
321
|
+
return toDisposable(() => {
|
|
322
|
+
this.computedByKey.delete(key);
|
|
323
|
+
for (const dep of deps) this.computedDeps.get(dep)?.delete(key);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private recompute(entry: ComputedEntry): void {
|
|
328
|
+
try {
|
|
329
|
+
this.writeKey(entry.key, entry.compute());
|
|
330
|
+
} catch (err) {
|
|
331
|
+
this.onError(err, `computed $/${entry.key}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private recomputeDependents(changedKey: string): void {
|
|
336
|
+
for (const [dep, keys] of this.computedDeps) {
|
|
337
|
+
const affected =
|
|
338
|
+
dep === changedKey ||
|
|
339
|
+
isDescendantKey(changedKey, dep) ||
|
|
340
|
+
isDescendantKey(dep, changedKey);
|
|
341
|
+
if (!affected) continue;
|
|
342
|
+
for (const key of keys) {
|
|
343
|
+
const entry = this.computedByKey.get(key);
|
|
344
|
+
if (entry) this.recompute(entry);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ------------------------------------------------------------ collections
|
|
350
|
+
|
|
351
|
+
collection<T = unknown>(path: BindingPath): CollectionOps<T> {
|
|
352
|
+
const read = (): T[] => {
|
|
353
|
+
const v = this.get<T[]>(path);
|
|
354
|
+
return Array.isArray(v) ? v : [];
|
|
355
|
+
};
|
|
356
|
+
const write = (items: T[]): void => this.set(path, items);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
all: () => read(),
|
|
360
|
+
at: (index) => read()[index],
|
|
361
|
+
find: (pred) => read().find(pred),
|
|
362
|
+
append: (...items) => write([...read(), ...items]),
|
|
363
|
+
prepend: (...items) => write([...items, ...read()]),
|
|
364
|
+
insertAt: (index, ...items) => {
|
|
365
|
+
const next = read();
|
|
366
|
+
next.splice(index, 0, ...items);
|
|
367
|
+
write(next);
|
|
368
|
+
},
|
|
369
|
+
removeAt: (index) => {
|
|
370
|
+
const next = read();
|
|
371
|
+
if (index < 0 || index >= next.length) return;
|
|
372
|
+
next.splice(index, 1);
|
|
373
|
+
write(next);
|
|
374
|
+
},
|
|
375
|
+
remove: (pred) => {
|
|
376
|
+
const items = read();
|
|
377
|
+
const kept = items.filter((item, i) => !pred(item, i));
|
|
378
|
+
const removed = items.length - kept.length;
|
|
379
|
+
if (removed > 0) write(kept);
|
|
380
|
+
return removed;
|
|
381
|
+
},
|
|
382
|
+
update: (pred, patch) => {
|
|
383
|
+
const items = read();
|
|
384
|
+
let changed = 0;
|
|
385
|
+
const next = items.map((item, i) => {
|
|
386
|
+
if (!pred(item, i)) return item;
|
|
387
|
+
changed++;
|
|
388
|
+
return typeof patch === 'function'
|
|
389
|
+
? (patch as (item: T) => T)(item)
|
|
390
|
+
: { ...(item as object), ...(patch as object) } as T;
|
|
391
|
+
});
|
|
392
|
+
if (changed > 0) write(next);
|
|
393
|
+
return changed;
|
|
394
|
+
},
|
|
395
|
+
replace: (items) => write([...items]),
|
|
396
|
+
filter: (pred) => read().filter(pred),
|
|
397
|
+
clear: () => write([]),
|
|
398
|
+
cap: (n) => {
|
|
399
|
+
const items = read();
|
|
400
|
+
if (items.length > n) write(items.slice(items.length - n));
|
|
401
|
+
},
|
|
402
|
+
get length() {
|
|
403
|
+
return read().length;
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// -------------------------------------------------------------- providers
|
|
409
|
+
|
|
410
|
+
registerDataProvider(def: DataProviderDefinition): Disposable {
|
|
411
|
+
const key = pathKey(def.namespace.startsWith('$/') ? def.namespace : `$/${def.namespace}`);
|
|
412
|
+
const entry: ProviderEntry = { def, loaded: false, loading: null, unloadTimer: null };
|
|
413
|
+
this.providers.set(key, entry);
|
|
414
|
+
if (def.load === 'eager') void this.loadProvider(key);
|
|
415
|
+
return toDisposable(() => {
|
|
416
|
+
if (entry.unloadTimer) clearTimeout(entry.unloadTimer);
|
|
417
|
+
void def.provider.unload?.(this);
|
|
418
|
+
this.providers.delete(key);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
listDataProviders(): DataProviderDefinition[] {
|
|
423
|
+
return [...this.providers.values()].map((e) => e.def);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A read or subscribe below a provider's namespace is what loads it. */
|
|
427
|
+
private touchProvider(key: string): void {
|
|
428
|
+
for (const [ns, entry] of this.providers) {
|
|
429
|
+
if (key !== ns && !isDescendantKey(key, ns)) continue;
|
|
430
|
+
if (entry.unloadTimer) {
|
|
431
|
+
clearTimeout(entry.unloadTimer);
|
|
432
|
+
entry.unloadTimer = null;
|
|
433
|
+
}
|
|
434
|
+
if (!entry.loaded && !entry.loading) void this.loadProvider(ns);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async loadProvider(ns: string): Promise<void> {
|
|
439
|
+
const entry = this.providers.get(ns);
|
|
440
|
+
if (!entry || entry.loaded || entry.loading) return;
|
|
441
|
+
const run = (async () => {
|
|
442
|
+
try {
|
|
443
|
+
await entry.def.provider.load(this);
|
|
444
|
+
entry.loaded = true;
|
|
445
|
+
} catch (err) {
|
|
446
|
+
this.onError(err, `data provider ${entry.def.namespace}`);
|
|
447
|
+
} finally {
|
|
448
|
+
entry.loading = null;
|
|
449
|
+
}
|
|
450
|
+
})();
|
|
451
|
+
entry.loading = run;
|
|
452
|
+
await run;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private maybeUnloadProvider(key: string): void {
|
|
456
|
+
for (const [ns, entry] of this.providers) {
|
|
457
|
+
if (key !== ns && !isDescendantKey(key, ns)) continue;
|
|
458
|
+
const { unloadAfter } = entry.def;
|
|
459
|
+
if (!unloadAfter || !entry.loaded) continue;
|
|
460
|
+
if (this.subscriberCount(ns) > 0) continue;
|
|
461
|
+
if (entry.unloadTimer) clearTimeout(entry.unloadTimer);
|
|
462
|
+
entry.unloadTimer = setTimeout(() => {
|
|
463
|
+
entry.unloadTimer = null;
|
|
464
|
+
if (this.subscriberCount(ns) > 0) return;
|
|
465
|
+
void entry.def.provider.unload?.(this);
|
|
466
|
+
entry.loaded = false;
|
|
467
|
+
}, unloadAfter);
|
|
468
|
+
entry.unloadTimer.unref?.();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------- schemas, persistence
|
|
473
|
+
|
|
474
|
+
registerSchema(schema: PathSchema): Disposable {
|
|
475
|
+
const key = pathKey(schema.path);
|
|
476
|
+
this.schemas.set(key, schema);
|
|
477
|
+
if (schema.initial !== undefined && this.readKey(key) === undefined) {
|
|
478
|
+
this.writeKey(key, schema.initial);
|
|
479
|
+
}
|
|
480
|
+
return toDisposable(() => this.schemas.delete(key));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
registerPersistence(adapter: PersistenceAdapter): Disposable {
|
|
484
|
+
this.persistence.add(adapter);
|
|
485
|
+
return toDisposable(() => {
|
|
486
|
+
const timer = this.persistTimers.get(adapter.id);
|
|
487
|
+
if (timer) clearTimeout(timer);
|
|
488
|
+
this.persistTimers.delete(adapter.id);
|
|
489
|
+
this.persistence.delete(adapter);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async hydrate(): Promise<void> {
|
|
494
|
+
for (const adapter of this.persistence) {
|
|
495
|
+
try {
|
|
496
|
+
const entries = await adapter.read();
|
|
497
|
+
this.batch(() => {
|
|
498
|
+
for (const [path, value] of Object.entries(entries)) {
|
|
499
|
+
this.set((path.startsWith('$/') ? path : `$/${path}`) as BindingPath, value);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
} catch (err) {
|
|
503
|
+
this.onError(err, `persistence adapter ${adapter.id}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private schedulePersist(changedKey: string): void {
|
|
509
|
+
for (const adapter of this.persistence) {
|
|
510
|
+
const owns = adapter.paths.some((p) => {
|
|
511
|
+
const key = adapterKey(p);
|
|
512
|
+
return p.endsWith('/')
|
|
513
|
+
? changedKey === key || isDescendantKey(changedKey, key)
|
|
514
|
+
: changedKey === key;
|
|
515
|
+
});
|
|
516
|
+
if (!owns) continue;
|
|
517
|
+
|
|
518
|
+
const existing = this.persistTimers.get(adapter.id);
|
|
519
|
+
if (existing) clearTimeout(existing);
|
|
520
|
+
const timer = setTimeout(() => {
|
|
521
|
+
this.persistTimers.delete(adapter.id);
|
|
522
|
+
const entries: Record<string, unknown> = {};
|
|
523
|
+
for (const p of adapter.paths) {
|
|
524
|
+
const key = adapterKey(p);
|
|
525
|
+
entries[`$/${key}`] = this.readKey(key);
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
void adapter.write(entries);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
this.onError(err, `persistence adapter ${adapter.id}`);
|
|
531
|
+
}
|
|
532
|
+
}, adapter.debounceMs ?? 250);
|
|
533
|
+
timer.unref?.();
|
|
534
|
+
this.persistTimers.set(adapter.id, timer);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --------------------------------------------------------------- snapshot
|
|
539
|
+
|
|
540
|
+
snapshot(scope?: ScopeName | string): Record<string, unknown> {
|
|
541
|
+
if (!scope) return structuredClone(this.root);
|
|
542
|
+
const key = pathKey(scope.startsWith('$/') ? scope : `$/${scope}`);
|
|
543
|
+
const value = this.readKey(key);
|
|
544
|
+
return value === undefined ? {} : structuredClone({ [key]: value });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
restore(snapshot: Record<string, unknown>): void {
|
|
548
|
+
this.batch(() => {
|
|
549
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
550
|
+
this.set(`$/${pathKey(key.startsWith('$/') ? key : `$/${key}`)}` as BindingPath, value);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
dispose(): void {
|
|
556
|
+
if (this.disposed) return;
|
|
557
|
+
this.disposed = true;
|
|
558
|
+
for (const timer of this.persistTimers.values()) clearTimeout(timer);
|
|
559
|
+
this.persistTimers.clear();
|
|
560
|
+
for (const entry of this.providers.values()) {
|
|
561
|
+
if (entry.unloadTimer) clearTimeout(entry.unloadTimer);
|
|
562
|
+
try {
|
|
563
|
+
void entry.def.provider.unload?.(this);
|
|
564
|
+
} catch {
|
|
565
|
+
/* teardown is best effort */
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
this.providers.clear();
|
|
569
|
+
this.subs.clear();
|
|
570
|
+
this.computedByKey.clear();
|
|
571
|
+
this.computedDeps.clear();
|
|
572
|
+
this.root = {};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* A computed defined as data. Deliberately tiny: `sum`, `count`, `length`,
|
|
578
|
+
* `first`, `join`, or a path reference. Anything more is a function.
|
|
579
|
+
*/
|
|
580
|
+
function compileSelect(
|
|
581
|
+
expr: string,
|
|
582
|
+
from: BindingPath[],
|
|
583
|
+
): (values: Record<string, unknown>) => unknown {
|
|
584
|
+
const [op, arg] = expr.split(':') as [string, string | undefined];
|
|
585
|
+
const pick = (values: Record<string, unknown>): unknown =>
|
|
586
|
+
values[arg ?? (from[0] as string)];
|
|
587
|
+
|
|
588
|
+
switch (op) {
|
|
589
|
+
case 'length':
|
|
590
|
+
case 'count':
|
|
591
|
+
return (v) => {
|
|
592
|
+
const x = pick(v);
|
|
593
|
+
return Array.isArray(x) ? x.length : x == null ? 0 : Object.keys(x as object).length;
|
|
594
|
+
};
|
|
595
|
+
case 'sum':
|
|
596
|
+
return (v) => {
|
|
597
|
+
const x = pick(v);
|
|
598
|
+
return Array.isArray(x) ? x.reduce((a: number, b) => a + Number(b || 0), 0) : 0;
|
|
599
|
+
};
|
|
600
|
+
case 'first':
|
|
601
|
+
return (v) => {
|
|
602
|
+
const x = pick(v);
|
|
603
|
+
return Array.isArray(x) ? x[0] : undefined;
|
|
604
|
+
};
|
|
605
|
+
case 'last':
|
|
606
|
+
return (v) => {
|
|
607
|
+
const x = pick(v);
|
|
608
|
+
return Array.isArray(x) ? x[x.length - 1] : undefined;
|
|
609
|
+
};
|
|
610
|
+
case 'not':
|
|
611
|
+
return (v) => !pick(v);
|
|
612
|
+
case 'join':
|
|
613
|
+
return (v) => {
|
|
614
|
+
const x = pick(v);
|
|
615
|
+
return Array.isArray(x) ? x.join(arg ?? ', ') : '';
|
|
616
|
+
};
|
|
617
|
+
case 'concat':
|
|
618
|
+
return (v) => from.map((p) => String(v[p] ?? '')).join('');
|
|
619
|
+
default:
|
|
620
|
+
// A bare path reference: `$/a/b`.
|
|
621
|
+
return (v) => v[expr];
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function createStore(): Store {
|
|
626
|
+
return new Store();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The key an adapter's path stands for.
|
|
631
|
+
*
|
|
632
|
+
* A trailing slash means "and everything under it", so it has to come off
|
|
633
|
+
* before the path is turned into a key: `segments` keeps the empty piece it
|
|
634
|
+
* leaves behind, so `$/a/b/` became the key `a/b/` and matched neither `a/b`
|
|
635
|
+
* nor anything below it. Every subtree adapter ever registered was inert.
|
|
636
|
+
*/
|
|
637
|
+
function adapterKey(path: string): string {
|
|
638
|
+
const bare = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
639
|
+
return pathKey(bare.startsWith('$/') ? bare : `$/${bare}`);
|
|
640
|
+
}
|