effectweb 0.1.1 → 0.2.2
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/dist/AsyncContent.js +23 -23
- package/dist/cache.d.ts +5 -14
- package/dist/cache.js +6 -49
- package/dist/collection.d.ts +9 -1
- package/dist/collection.js +52 -2
- package/dist/dom.d.ts +1 -1
- package/dist/dom.js +36 -25
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -3
- package/dist/owner.d.ts +35 -0
- package/dist/owner.js +137 -0
- package/dist/pages.d.ts +11 -1
- package/dist/pages.js +20 -3
- package/dist/program.d.ts +5 -3
- package/dist/program.js +4 -2
- package/dist/query.d.ts +5 -1
- package/dist/resource.js +6 -1
- package/dist/runtime.d.ts +3 -1
- package/dist/session.d.ts +16 -25
- package/dist/session.js +107 -95
- package/dist/share.d.ts +5 -1
- package/dist/share.js +9 -3
- package/dist/snapshot.d.ts +5 -0
- package/dist/snapshot.js +31 -0
- package/dist/tasks.d.ts +10 -0
- package/dist/tasks.js +8 -1
- package/dist/testing.d.ts +2 -2
- package/package.json +1 -1
package/dist/AsyncContent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
1
|
+
import * as _ew_dom from "./dom.js";
|
|
2
2
|
import { Cause, Effect, Option } from "effect";
|
|
3
3
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
4
4
|
import { component } from "./component.js";
|
|
@@ -44,28 +44,28 @@ const implementation = component({
|
|
|
44
44
|
...model,
|
|
45
45
|
visible: true
|
|
46
46
|
} }),
|
|
47
|
-
view:
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}, (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}, (
|
|
59
|
-
}, (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}, (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}, (
|
|
67
|
-
}, (
|
|
68
|
-
|
|
47
|
+
view: /* @__PURE__ */ _ew_dom.compiled((_ew_scope_1, _ew_parent_2, _ew_before_3) => {
|
|
48
|
+
const _ew_derived_4 = _ew_scope_1.derive(() => [_ew_scope_1.value?.props], () => ((_ew_capture_5) => _ew_capture_5.props)(_ew_scope_1.value));
|
|
49
|
+
const _ew_derived_6 = _ew_scope_1.derive(() => [_ew_derived_4()?.result], () => ((_ew_capture_7) => AsyncResult.value(_ew_capture_7.result))(_ew_derived_4()));
|
|
50
|
+
const _ew_derived_8 = _ew_scope_1.derive(() => [_ew_derived_4()?.result, _ew_derived_4()?.result?.waiting], () => ((_ew_capture_9) => AsyncResult.isFailure(_ew_capture_9.result) && !_ew_capture_9.result.waiting ? _ew_capture_9.result : undefined)(_ew_derived_4()));
|
|
51
|
+
_ew_dom.branch(_ew_scope_1, _ew_parent_2, _ew_before_3, () => Option.isSome(_ew_derived_6()), (_ew_scope_1, _ew_parent_10, _ew_before_11) => {
|
|
52
|
+
_ew_dom.text(_ew_scope_1, _ew_parent_10, _ew_before_11, () => [_ew_derived_4(), _ew_derived_6()?.value], () => _ew_derived_4().content(_ew_derived_6().value));
|
|
53
|
+
_ew_dom.branch(_ew_scope_1, _ew_parent_10, _ew_before_11, () => _ew_derived_4().result.waiting, (_ew_scope_1, _ew_parent_12, _ew_before_13) => {
|
|
54
|
+
_ew_dom.text(_ew_scope_1, _ew_parent_12, _ew_before_13, () => [_ew_derived_4()?.refreshing], () => _ew_derived_4().refreshing);
|
|
55
|
+
}, (_ew_scope_1, _ew_parent_12, _ew_before_13) => { });
|
|
56
|
+
_ew_dom.branch(_ew_scope_1, _ew_parent_10, _ew_before_11, () => _ew_derived_8() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_14, _ew_before_15) => {
|
|
57
|
+
_ew_dom.text(_ew_scope_1, _ew_parent_14, _ew_before_15, () => [_ew_derived_4(), _ew_derived_8()?.cause], () => _ew_derived_4().failure(_ew_derived_8().cause));
|
|
58
|
+
}, (_ew_scope_1, _ew_parent_14, _ew_before_15) => { });
|
|
59
|
+
}, (_ew_scope_1, _ew_parent_10, _ew_before_11) => {
|
|
60
|
+
_ew_dom.branch(_ew_scope_1, _ew_parent_10, _ew_before_11, () => _ew_derived_8() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_16, _ew_before_17) => {
|
|
61
|
+
_ew_dom.text(_ew_scope_1, _ew_parent_16, _ew_before_17, () => [_ew_derived_4(), _ew_derived_8()?.cause], () => _ew_derived_4().failure(_ew_derived_8().cause));
|
|
62
|
+
}, (_ew_scope_1, _ew_parent_16, _ew_before_17) => {
|
|
63
|
+
_ew_dom.branch(_ew_scope_1, _ew_parent_16, _ew_before_17, () => _ew_scope_1.value.pending, (_ew_scope_1, _ew_parent_18, _ew_before_19) => {
|
|
64
|
+
_ew_dom.branch(_ew_scope_1, _ew_parent_18, _ew_before_19, () => _ew_scope_1.value.visible, (_ew_scope_1, _ew_parent_20, _ew_before_21) => {
|
|
65
|
+
_ew_dom.text(_ew_scope_1, _ew_parent_20, _ew_before_21, () => [_ew_derived_4()?.pending], () => _ew_derived_4().pending);
|
|
66
|
+
}, (_ew_scope_1, _ew_parent_20, _ew_before_21) => { });
|
|
67
|
+
}, (_ew_scope_1, _ew_parent_18, _ew_before_19) => {
|
|
68
|
+
_ew_dom.text(_ew_scope_1, _ew_parent_18, _ew_before_19, () => [_ew_derived_4()?.empty], () => _ew_derived_4().empty);
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
});
|
package/dist/cache.d.ts
CHANGED
|
@@ -2,14 +2,13 @@ import { Effect } from 'effect';
|
|
|
2
2
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
3
3
|
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
4
4
|
import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
|
|
5
|
-
import { type UiLoad, type UiPage } from './load.js';
|
|
6
5
|
import type { Query } from './query.js';
|
|
7
6
|
import type { UiRuntime } from './runtime.js';
|
|
8
|
-
export { loadEffect, type UiLoad
|
|
7
|
+
export { loadEffect, type UiLoad } from './load.js';
|
|
9
8
|
export { shareValue } from './share.js';
|
|
10
|
-
export declare function
|
|
11
|
-
export declare function
|
|
12
|
-
declare function
|
|
9
|
+
export declare function makeQueryCache(): QueryCache<never>;
|
|
10
|
+
export declare function makeQueryCache<R>(runtime: UiRuntime<R>): QueryCache<R>;
|
|
11
|
+
declare function createQueryCache<R>(runtime?: UiRuntime<R>): {
|
|
13
12
|
registry: AtomRegistry.AtomRegistry;
|
|
14
13
|
generation: Atom.Writable<number, number>;
|
|
15
14
|
resource<A, E = unknown>(key: string, load: () => Effect.Effect<A, E>): Atom.Atom<AsyncResult.AsyncResult<A, E>>;
|
|
@@ -21,12 +20,4 @@ declare function createUiModel<R>(runtime?: UiRuntime<R>): {
|
|
|
21
20
|
resetResources(): void;
|
|
22
21
|
dispose(): void;
|
|
23
22
|
};
|
|
24
|
-
export type
|
|
25
|
-
/** Cursor progress and prior successful pages belong to the resource, including retries. */
|
|
26
|
-
export declare function makePagedResource<A, Cursor>(registry: AtomRegistry.AtomRegistry, load: (cursor: Cursor | undefined) => UiLoad<UiPage<A, Cursor>>, itemKey: (item: A) => string): {
|
|
27
|
-
atom: Atom.Atom<AsyncResult.AsyncResult<UiPage<A, Cursor> & {
|
|
28
|
-
pageCount: number;
|
|
29
|
-
}, unknown>>;
|
|
30
|
-
more(): void;
|
|
31
|
-
refresh(): void;
|
|
32
|
-
};
|
|
23
|
+
export type QueryCache<R = never> = ReturnType<typeof createQueryCache<R>>;
|
package/dist/cache.js
CHANGED
|
@@ -6,14 +6,14 @@ import { loadEffect } from './load.js';
|
|
|
6
6
|
import { shareValue } from './share.js';
|
|
7
7
|
export { loadEffect } from './load.js';
|
|
8
8
|
export { shareValue } from './share.js';
|
|
9
|
-
export function
|
|
10
|
-
return
|
|
9
|
+
export function makeQueryCache(runtime) {
|
|
10
|
+
return createQueryCache(runtime);
|
|
11
11
|
}
|
|
12
|
-
function
|
|
12
|
+
function createQueryCache(runtime) {
|
|
13
13
|
const registry = AtomRegistry.make({ defaultIdleTTL: 30_000 });
|
|
14
14
|
const generation = Atom.keepAlive(Atom.make(0));
|
|
15
15
|
const resources = new Map();
|
|
16
|
-
const acquire = (key, load) => {
|
|
16
|
+
const acquire = (key, load, share = shareValue) => {
|
|
17
17
|
let entry = resources.get(key);
|
|
18
18
|
if (entry)
|
|
19
19
|
entry.load = load;
|
|
@@ -24,7 +24,7 @@ function createUiModel(runtime) {
|
|
|
24
24
|
const previous = Option.flatMap(get.self(), AsyncResult.value);
|
|
25
25
|
return loadEffect(() => next.load()).pipe(Effect.map((value) => {
|
|
26
26
|
next.loadedAt = Date.now();
|
|
27
|
-
return Option.isSome(previous) ?
|
|
27
|
+
return Option.isSome(previous) ? share(previous.value, value) : value;
|
|
28
28
|
}));
|
|
29
29
|
}),
|
|
30
30
|
};
|
|
@@ -47,7 +47,7 @@ function createUiModel(runtime) {
|
|
|
47
47
|
const { entry, atom } = acquire(queryKey(definition, args), () => {
|
|
48
48
|
const effect = Effect.suspend(() => definition.load(args));
|
|
49
49
|
return runtime ? runtime.provide(effect) : effect;
|
|
50
|
-
});
|
|
50
|
+
}, definition.share);
|
|
51
51
|
entry.queryId = definition.id;
|
|
52
52
|
if (registry.getNodes().has(atom) &&
|
|
53
53
|
entry.loadedAt !== undefined &&
|
|
@@ -102,46 +102,3 @@ function createUiModel(runtime) {
|
|
|
102
102
|
},
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
-
/** Cursor progress and prior successful pages belong to the resource, including retries. */
|
|
106
|
-
export function makePagedResource(registry, load, itemKey) {
|
|
107
|
-
const requested = Atom.make(1);
|
|
108
|
-
const atom = Atom.make((get) => {
|
|
109
|
-
const count = get(requested);
|
|
110
|
-
const previous = Option.getOrUndefined(Option.flatMap(get.self(), AsyncResult.value));
|
|
111
|
-
const append = previous && count === previous.pageCount + 1 ? previous : undefined;
|
|
112
|
-
return loadEffect(() => load(append?.next)).pipe(Effect.map((page) => {
|
|
113
|
-
const known = new Set(append?.items.map(itemKey));
|
|
114
|
-
const incoming = page.items.filter((item) => {
|
|
115
|
-
const key = itemKey(item);
|
|
116
|
-
if (known.has(key))
|
|
117
|
-
return false;
|
|
118
|
-
known.add(key);
|
|
119
|
-
return true;
|
|
120
|
-
});
|
|
121
|
-
return {
|
|
122
|
-
...page,
|
|
123
|
-
items: append ? [...append.items, ...incoming] : incoming,
|
|
124
|
-
pageCount: append ? count : 1,
|
|
125
|
-
};
|
|
126
|
-
}));
|
|
127
|
-
}).pipe(Atom.setIdleTTL(0));
|
|
128
|
-
return {
|
|
129
|
-
atom,
|
|
130
|
-
more() {
|
|
131
|
-
const result = registry.get(atom);
|
|
132
|
-
const previous = Option.getOrUndefined(AsyncResult.value(result));
|
|
133
|
-
if (result.waiting || !previous || previous.next === undefined)
|
|
134
|
-
return;
|
|
135
|
-
Atom.batch(() => {
|
|
136
|
-
registry.set(requested, previous.pageCount + 1);
|
|
137
|
-
registry.refresh(atom);
|
|
138
|
-
});
|
|
139
|
-
},
|
|
140
|
-
refresh() {
|
|
141
|
-
Atom.batch(() => {
|
|
142
|
-
registry.set(requested, 1);
|
|
143
|
-
registry.refresh(atom);
|
|
144
|
-
});
|
|
145
|
-
},
|
|
146
|
-
};
|
|
147
|
-
}
|
package/dist/collection.d.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface Rows<A> {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function collection<A>(identity: (item: A, index: number) => Identity): {
|
|
12
12
|
from: (items: readonly A[]) => Rows<A>;
|
|
13
|
+
share: {
|
|
14
|
+
<B extends A>(previous: readonly B[], next: B[]): B[];
|
|
15
|
+
<B extends A>(previous: readonly B[], next: readonly B[]): readonly B[];
|
|
16
|
+
};
|
|
13
17
|
};
|
|
14
|
-
/**
|
|
18
|
+
/** Use positional identity for ordered values without stable entity IDs. */
|
|
15
19
|
export declare function sequence<A>(items: readonly A[]): Rows<A>;
|
|
20
|
+
/** Rows keyed by their domain IDs. Reuses the wrapper for the same immutable array. */
|
|
21
|
+
export declare function entities<A extends {
|
|
22
|
+
readonly id: Identity;
|
|
23
|
+
}>(items: readonly A[] | undefined): Rows<A>;
|
package/dist/collection.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { shareValue } from './share.js';
|
|
1
2
|
export function collection(identity) {
|
|
2
3
|
const cache = new WeakMap();
|
|
4
|
+
const comparisons = new WeakMap();
|
|
3
5
|
const from = (items) => {
|
|
4
6
|
const cached = cache.get(items);
|
|
5
7
|
if (cached)
|
|
@@ -15,10 +17,58 @@ export function collection(identity) {
|
|
|
15
17
|
cache.set(items, rows);
|
|
16
18
|
return rows;
|
|
17
19
|
};
|
|
18
|
-
|
|
20
|
+
function share(previous, next) {
|
|
21
|
+
if (previous === next)
|
|
22
|
+
return previous;
|
|
23
|
+
let pairs = comparisons.get(previous);
|
|
24
|
+
const cached = pairs?.get(next);
|
|
25
|
+
if (cached)
|
|
26
|
+
return cached;
|
|
27
|
+
let byIdentity;
|
|
28
|
+
let result;
|
|
29
|
+
let equal = previous.length === next.length;
|
|
30
|
+
for (let index = 0; index < next.length; index++) {
|
|
31
|
+
const item = next[index];
|
|
32
|
+
if (index < previous.length && Object.is(previous[index], item))
|
|
33
|
+
continue;
|
|
34
|
+
const key = identity(item, index);
|
|
35
|
+
let old = previous[index];
|
|
36
|
+
if (index >= previous.length || !Object.is(identity(old, index), key)) {
|
|
37
|
+
if (!byIdentity) {
|
|
38
|
+
byIdentity = new Map(previous.map((value, i) => [identity(value, i), value]));
|
|
39
|
+
const keys = next.map(identity);
|
|
40
|
+
if (byIdentity.size !== previous.length || new Set(keys).size !== keys.length)
|
|
41
|
+
throw new Error('Duplicate collection identity. Identity must be unique within the collection.');
|
|
42
|
+
}
|
|
43
|
+
old = byIdentity.get(key);
|
|
44
|
+
}
|
|
45
|
+
const value = old === undefined ? item : shareValue(old, item);
|
|
46
|
+
if (!Object.is(value, previous[index]))
|
|
47
|
+
equal = false;
|
|
48
|
+
if (!Object.is(value, item) && !result)
|
|
49
|
+
result = next.slice();
|
|
50
|
+
if (result)
|
|
51
|
+
result[index] = value;
|
|
52
|
+
}
|
|
53
|
+
const shared = equal ? previous : (result ?? next);
|
|
54
|
+
if (!pairs) {
|
|
55
|
+
pairs = new WeakMap();
|
|
56
|
+
comparisons.set(previous, pairs);
|
|
57
|
+
}
|
|
58
|
+
pairs.set(next, shared);
|
|
59
|
+
return shared;
|
|
60
|
+
}
|
|
61
|
+
return { from, share };
|
|
19
62
|
}
|
|
20
63
|
const positions = collection((_item, index) => index);
|
|
21
|
-
/**
|
|
64
|
+
/** Use positional identity for ordered values without stable entity IDs. */
|
|
22
65
|
export function sequence(items) {
|
|
23
66
|
return positions.from(items);
|
|
24
67
|
}
|
|
68
|
+
const empty = [];
|
|
69
|
+
const identified = collection((item) => item.id);
|
|
70
|
+
/** Rows keyed by their domain IDs. Reuses the wrapper for the same immutable array. */
|
|
71
|
+
export function entities(items) {
|
|
72
|
+
// The collection retains, filters and slices supplied items; it never inserts wider values.
|
|
73
|
+
return identified.from(items ?? empty);
|
|
74
|
+
}
|
package/dist/dom.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export declare function slot<A = void>(_render: (value: A) => JSX.Element): Slot
|
|
|
45
45
|
/** Compiler output. Captures follow their declaring scope; placements own independent scopes. */
|
|
46
46
|
export declare function compiledSlot<M, E, A>(owner: Scope<M, E>, build: Build<A, E>): Slot<A>;
|
|
47
47
|
/** This declaration is a compiler marker, never a component setup callback. */
|
|
48
|
-
export declare function view<M, E>(_render: (model: M, send: Send<E>) => JSX.Element): View<M, E>;
|
|
48
|
+
export declare function view<M, E = never>(_render: (model: M, send: Send<E>) => JSX.Element): View<M, E>;
|
|
49
49
|
export declare function compiled<M, E>(build: Build<M, E>): View<M, E>;
|
|
50
50
|
export declare function mountView<M, E>(parent: Node, definition: View<M, E>, source: Program<M, E>, options?: {
|
|
51
51
|
onError?: ReportError;
|
package/dist/dom.js
CHANGED
|
@@ -78,7 +78,7 @@ export class Scope {
|
|
|
78
78
|
const contentBrand = Symbol('compiled content');
|
|
79
79
|
/** Compiler marker: declare inside view(), or directly in a compiled component prop. */
|
|
80
80
|
export function slot(_render) {
|
|
81
|
-
throw new Error('
|
|
81
|
+
throw new Error('EffectWeb slot reached runtime without the EffectWeb JSX compiler');
|
|
82
82
|
}
|
|
83
83
|
// Placement values are opaque to structural sharing, which only traverses plain data.
|
|
84
84
|
class SlotPlacement {
|
|
@@ -143,7 +143,7 @@ export function compiledSlot(owner, build) {
|
|
|
143
143
|
}
|
|
144
144
|
/** This declaration is a compiler marker, never a component setup callback. */
|
|
145
145
|
export function view(_render) {
|
|
146
|
-
throw new Error('
|
|
146
|
+
throw new Error('EffectWeb view reached runtime without the EffectWeb JSX compiler');
|
|
147
147
|
}
|
|
148
148
|
export function compiled(build) {
|
|
149
149
|
return Object.assign(() => {
|
|
@@ -440,9 +440,9 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
|
|
|
440
440
|
throw new Error('Object lists need domain identity. Declare collection(identity) once; do not add JSX keys.');
|
|
441
441
|
return key;
|
|
442
442
|
});
|
|
443
|
-
if (new Set(identities).size !== identities.length)
|
|
444
|
-
throw new Error('Duplicate collection identity. Identity must be unique within the rendered collection.');
|
|
445
443
|
const keep = new Set(identities);
|
|
444
|
+
if (keep.size !== identities.length)
|
|
445
|
+
throw new Error('Duplicate collection identity. Identity must be unique within the rendered collection.');
|
|
446
446
|
for (const [key, row] of rows) {
|
|
447
447
|
if (keep.has(key))
|
|
448
448
|
continue;
|
|
@@ -485,29 +485,40 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
|
|
|
485
485
|
row.scope.set([item, index]);
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
let
|
|
491
|
-
for (let index =
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
node =
|
|
488
|
+
// Retained prefixes are already ordered. New tail rows were appended above and
|
|
489
|
+
// removed tail rows were disposed, so edits, appends and truncations need no moves.
|
|
490
|
+
let orderChanged = false;
|
|
491
|
+
for (let index = 0; index < Math.min(previousIdentities.length, identities.length); index++) {
|
|
492
|
+
if (previousIdentities[index] !== identities[index]) {
|
|
493
|
+
orderChanged = true;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (orderChanged) {
|
|
498
|
+
const positions = new Map(previousIdentities.map((key, index) => [key, index]));
|
|
499
|
+
const stationary = stationaryIndices(identities.map((key) => positions.get(key) ?? -1));
|
|
500
|
+
let anchor = end;
|
|
501
|
+
for (let index = identities.length - 1; index >= 0; index--) {
|
|
502
|
+
const row = rows.get(identities[index]);
|
|
503
|
+
if (!stationary.has(index) && row.end.nextSibling !== anchor) {
|
|
504
|
+
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
|
505
|
+
let node = row.start;
|
|
506
|
+
while (node) {
|
|
507
|
+
const next = node.nextSibling;
|
|
508
|
+
const move = Reflect.get(target, 'moveBefore');
|
|
509
|
+
if (typeof move === 'function' && target.isConnected && node.isConnected)
|
|
510
|
+
move.call(target, node, anchor);
|
|
511
|
+
else
|
|
512
|
+
target.insertBefore(node, anchor);
|
|
513
|
+
if (node === row.end)
|
|
514
|
+
break;
|
|
515
|
+
node = next;
|
|
516
|
+
}
|
|
517
|
+
if (focused?.isConnected && document.activeElement !== focused)
|
|
518
|
+
focused.focus({ preventScroll: true });
|
|
506
519
|
}
|
|
507
|
-
|
|
508
|
-
focused.focus({ preventScroll: true });
|
|
520
|
+
anchor = row.start;
|
|
509
521
|
}
|
|
510
|
-
anchor = row.start;
|
|
511
522
|
}
|
|
512
523
|
previousList = next;
|
|
513
524
|
previousOuter = nextOuter;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export type { Snapshot } from './snapshot.js';
|
|
2
|
+
export { modelOwner, type ModelOwner, type DisposableOwner, type TaskPolicy } from './owner.js';
|
|
3
|
+
export { collection, entities, sequence, type Rows } from './collection.js';
|
|
2
4
|
export { component, localComponent, programView } from './component.js';
|
|
3
5
|
export { patchModel } from './state.js';
|
|
4
6
|
export { defineActions, type ActionMessage } from './actions.js';
|
|
@@ -18,5 +20,5 @@ export { AsyncContent, type AsyncContentProps } from './AsyncContent.js';
|
|
|
18
20
|
export { defineTasks, type TaskDefinition, type TasksModel, type TasksMessage, type TaskResults, } from './tasks.js';
|
|
19
21
|
export { uiRuntime, type UiRuntime } from './runtime.js';
|
|
20
22
|
export { query, type Query } from './query.js';
|
|
21
|
-
export {
|
|
22
|
-
export { queryResource,
|
|
23
|
+
export { makeQueryCache, type QueryCache } from './cache.js';
|
|
24
|
+
export { queryResource, observeQuery, lifetime, projectionCache, sessionGroup, type SessionContext, type Read, } from './session.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { modelOwner } from './owner.js';
|
|
2
|
+
export { collection, entities, sequence } from './collection.js';
|
|
2
3
|
export { component, localComponent, programView } from './component.js';
|
|
3
4
|
export { patchModel } from './state.js';
|
|
4
5
|
export { defineActions } from './actions.js';
|
|
@@ -17,5 +18,5 @@ export { AsyncContent } from './AsyncContent.js';
|
|
|
17
18
|
export { defineTasks, } from './tasks.js';
|
|
18
19
|
export { uiRuntime } from './runtime.js';
|
|
19
20
|
export { query } from './query.js';
|
|
20
|
-
export {
|
|
21
|
-
export { queryResource,
|
|
21
|
+
export { makeQueryCache } from './cache.js';
|
|
22
|
+
export { queryResource, observeQuery, lifetime, projectionCache, sessionGroup, } from './session.js';
|
package/dist/owner.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Snapshot } from './snapshot.js';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { type Program } from './program.js';
|
|
4
|
+
import { type ReportError } from './errors.js';
|
|
5
|
+
import type { UiRuntime } from './runtime.js';
|
|
6
|
+
export type TaskPolicy = 'replace' | 'drop' | 'parallel';
|
|
7
|
+
export interface DisposableOwner {
|
|
8
|
+
readonly disposed: boolean;
|
|
9
|
+
readonly own: <A extends {
|
|
10
|
+
dispose(): void;
|
|
11
|
+
}>(resource: A) => A;
|
|
12
|
+
}
|
|
13
|
+
export interface ModelOwner<Model extends object, R = never> extends DisposableOwner {
|
|
14
|
+
readonly source: Program<Model, never>;
|
|
15
|
+
readonly read: () => Snapshot<Model>;
|
|
16
|
+
readonly patch: (changes: Partial<Model>) => void;
|
|
17
|
+
readonly edit: <K extends keyof Model>(key: K, change: (value: Snapshot<Model[K]>) => Snapshot<Model[K]>) => void;
|
|
18
|
+
/** Synchronous transaction. Reads see staged changes; a throw discards changes and work. */
|
|
19
|
+
readonly transaction: <A>(work: () => A & (A extends PromiseLike<unknown> ? never : unknown)) => A;
|
|
20
|
+
readonly run: (slot: string, effect: Effect.Effect<unknown, unknown, R>, policy?: TaskPolicy) => void;
|
|
21
|
+
readonly cancel: (slot: string) => void;
|
|
22
|
+
readonly isRunning: (slot: string) => boolean;
|
|
23
|
+
readonly awaitIdle: () => Promise<void>;
|
|
24
|
+
readonly dispose: () => void;
|
|
25
|
+
}
|
|
26
|
+
type Options = {
|
|
27
|
+
checkSnapshots?: boolean;
|
|
28
|
+
name?: string;
|
|
29
|
+
onDefect?: ReportError;
|
|
30
|
+
};
|
|
31
|
+
export declare function modelOwner<Model extends object>(initial: Model, options?: Options): ModelOwner<Model>;
|
|
32
|
+
export declare function modelOwner<Model extends object, R>(initial: Model, options: Options & {
|
|
33
|
+
runtime: UiRuntime<R>;
|
|
34
|
+
}): ModelOwner<Model, R>;
|
|
35
|
+
export {};
|
package/dist/owner.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { protectSnapshot, checkSnapshotsByDefault } from './snapshot.js';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { program } from './program.js';
|
|
4
|
+
import { patchModel } from './state.js';
|
|
5
|
+
import { runAll, reportError } from './errors.js';
|
|
6
|
+
/** Immutable model publication and task ownership, using the same program queue as components. */
|
|
7
|
+
export function modelOwner(initial, options = {}) {
|
|
8
|
+
let disposed = false, nextTask = 0;
|
|
9
|
+
let staged;
|
|
10
|
+
const groups = new Map();
|
|
11
|
+
const cleanups = [];
|
|
12
|
+
const source = program({
|
|
13
|
+
initial,
|
|
14
|
+
...options,
|
|
15
|
+
update(model, operations) {
|
|
16
|
+
const single = operations.length === 1 ? operations[0] : undefined;
|
|
17
|
+
if (single?.type === 'Patch')
|
|
18
|
+
return { model: patchModel(model, single.changes) };
|
|
19
|
+
const commands = new Map();
|
|
20
|
+
const cancel = new Set();
|
|
21
|
+
const stop = (slot) => {
|
|
22
|
+
for (const key of groups.get(slot) ?? []) {
|
|
23
|
+
cancel.add(key);
|
|
24
|
+
commands.delete(key);
|
|
25
|
+
}
|
|
26
|
+
groups.delete(slot);
|
|
27
|
+
};
|
|
28
|
+
for (const operation of operations) {
|
|
29
|
+
if (operation.type === 'Patch')
|
|
30
|
+
model = patchModel(model, operation.changes);
|
|
31
|
+
else if (operation.type === 'Cancel')
|
|
32
|
+
stop(operation.slot);
|
|
33
|
+
else {
|
|
34
|
+
const { slot, effect, policy } = operation;
|
|
35
|
+
if (policy === 'drop' && groups.has(slot))
|
|
36
|
+
continue;
|
|
37
|
+
if (policy === 'replace')
|
|
38
|
+
stop(slot);
|
|
39
|
+
const key = `${slot}:${++nextTask}`;
|
|
40
|
+
const group = groups.get(slot) ?? new Set();
|
|
41
|
+
groups.set(slot, group);
|
|
42
|
+
group.add(key);
|
|
43
|
+
commands.set(key, {
|
|
44
|
+
slot: key,
|
|
45
|
+
action: Effect.asVoid(effect).pipe(Effect.ensuring(Effect.sync(() => {
|
|
46
|
+
group.delete(key);
|
|
47
|
+
if (!group.size && groups.get(slot) === group)
|
|
48
|
+
groups.delete(slot);
|
|
49
|
+
}))),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
model,
|
|
55
|
+
cancel: [...cancel],
|
|
56
|
+
commands: [...commands.values()].map((command) => options.runtime ? options.runtime.command(command) : command),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const read = () => protectSnapshot(staged?.model ?? source.model(), options.checkSnapshots ?? checkSnapshotsByDefault);
|
|
61
|
+
const submit = (operation) => {
|
|
62
|
+
if (disposed)
|
|
63
|
+
return;
|
|
64
|
+
if (staged) {
|
|
65
|
+
staged.operations.push(operation);
|
|
66
|
+
if (operation.type === 'Patch')
|
|
67
|
+
staged.model = patchModel(staged.model, operation.changes);
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
source.send([operation]);
|
|
71
|
+
};
|
|
72
|
+
const patch = (changes) => submit({ type: 'Patch', changes });
|
|
73
|
+
const dispose = () => {
|
|
74
|
+
if (disposed)
|
|
75
|
+
return;
|
|
76
|
+
disposed = true;
|
|
77
|
+
source.dispose();
|
|
78
|
+
groups.clear();
|
|
79
|
+
runAll(cleanups.splice(0).reverse(), options.onDefect ?? reportError);
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose },
|
|
83
|
+
read,
|
|
84
|
+
patch,
|
|
85
|
+
edit: (key, change) => {
|
|
86
|
+
if (!disposed) {
|
|
87
|
+
const changes = {};
|
|
88
|
+
// Readonly changes remain immutable model data; no clone is needed for a no-op.
|
|
89
|
+
changes[key] = change(read()[key]);
|
|
90
|
+
patch(changes);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
transaction(work) {
|
|
94
|
+
if (disposed)
|
|
95
|
+
throw new Error('Cannot start a transaction on a disposed model owner.');
|
|
96
|
+
const parent = staged;
|
|
97
|
+
const batch = { model: read(), operations: [] };
|
|
98
|
+
staged = batch;
|
|
99
|
+
try {
|
|
100
|
+
const result = work();
|
|
101
|
+
if (result &&
|
|
102
|
+
(typeof result === 'object' || typeof result === 'function') &&
|
|
103
|
+
'then' in result &&
|
|
104
|
+
typeof result.then === 'function')
|
|
105
|
+
throw new TypeError('Model transactions must be synchronous. Run async work as an owned Effect.');
|
|
106
|
+
staged = parent;
|
|
107
|
+
if (!disposed) {
|
|
108
|
+
if (parent) {
|
|
109
|
+
parent.model = batch.model;
|
|
110
|
+
parent.operations.push(...batch.operations);
|
|
111
|
+
}
|
|
112
|
+
else if (batch.operations.length)
|
|
113
|
+
source.send(batch.operations);
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
staged = parent;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
run: (slot, effect, policy = 'replace') => submit({ type: 'Run', slot, effect, policy }),
|
|
122
|
+
cancel: (slot) => submit({ type: 'Cancel', slot }),
|
|
123
|
+
isRunning: (slot) => groups.has(slot),
|
|
124
|
+
awaitIdle: source.awaitIdle,
|
|
125
|
+
get disposed() {
|
|
126
|
+
return disposed;
|
|
127
|
+
},
|
|
128
|
+
own(resource) {
|
|
129
|
+
if (disposed)
|
|
130
|
+
resource.dispose();
|
|
131
|
+
else
|
|
132
|
+
cleanups.push(() => resource.dispose());
|
|
133
|
+
return resource;
|
|
134
|
+
},
|
|
135
|
+
dispose,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/dist/pages.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Cause } from 'effect';
|
|
2
2
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
3
3
|
import type { UiLoad, UiPage } from './load.js';
|
|
4
|
-
import { type Transition } from './program.js';
|
|
4
|
+
import { type Transition, type Send } from './program.js';
|
|
5
5
|
export interface PagesModel<Props, A, Cursor> {
|
|
6
6
|
readonly props: Props;
|
|
7
7
|
readonly key: string | undefined;
|
|
@@ -32,6 +32,16 @@ export declare function pages<Props, A, Cursor>(definition: {
|
|
|
32
32
|
load: (props: Props, cursor: Cursor | undefined) => UiLoad<UiPage<A, Cursor>>;
|
|
33
33
|
itemKey: (item: A) => string;
|
|
34
34
|
}): {
|
|
35
|
+
/** Own pagination directly in a controller; components can use the same reducer. */
|
|
36
|
+
create(props: Props): {
|
|
37
|
+
send: Send<PagesMessage<A, Cursor>>;
|
|
38
|
+
receive: (props: Props) => void;
|
|
39
|
+
activeSlots: () => readonly string[];
|
|
40
|
+
awaitIdle: (slot?: string) => Promise<void>;
|
|
41
|
+
model: () => Readonly<PagesModel<Props, A, Cursor>>;
|
|
42
|
+
subscribe: (listener: (model: Readonly<PagesModel<Props, A, Cursor>>) => void) => () => void;
|
|
43
|
+
dispose: () => void;
|
|
44
|
+
};
|
|
35
45
|
init: (props: Props) => PagesModel<Props, A, Cursor>;
|
|
36
46
|
receive(model: PagesModel<Props, A, Cursor>, props: Props, options?: {
|
|
37
47
|
refresh?: boolean;
|
package/dist/pages.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { shareValue } from './share.js';
|
|
1
2
|
import { Cause, Option } from 'effect';
|
|
2
3
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
3
|
-
import { effectCommand } from './program.js';
|
|
4
|
+
import { effectCommand, program } from './program.js';
|
|
4
5
|
import { available } from './resource.js';
|
|
5
6
|
/**
|
|
6
7
|
* Pagination owns the `page` command slot. Key changes clear results and replace the request;
|
|
@@ -25,7 +26,7 @@ export function pages(definition) {
|
|
|
25
26
|
],
|
|
26
27
|
};
|
|
27
28
|
};
|
|
28
|
-
|
|
29
|
+
const pagination = {
|
|
29
30
|
init: (props) => ({
|
|
30
31
|
props,
|
|
31
32
|
key: undefined,
|
|
@@ -35,7 +36,8 @@ export function pages(definition) {
|
|
|
35
36
|
receive(model, props, options = {}) {
|
|
36
37
|
const key = definition.key(props);
|
|
37
38
|
if (key === model.key) {
|
|
38
|
-
const
|
|
39
|
+
const shared = shareValue(model.props, props);
|
|
40
|
+
const next = shared === model.props ? model : { ...model, props: shared };
|
|
39
41
|
return options.refresh ? request(next, false) : { model: next };
|
|
40
42
|
}
|
|
41
43
|
const next = {
|
|
@@ -83,4 +85,19 @@ export function pages(definition) {
|
|
|
83
85
|
}
|
|
84
86
|
},
|
|
85
87
|
};
|
|
88
|
+
return {
|
|
89
|
+
...pagination,
|
|
90
|
+
/** Own pagination directly in a controller; components can use the same reducer. */
|
|
91
|
+
create(props) {
|
|
92
|
+
const source = program({
|
|
93
|
+
initial: pagination.init(props),
|
|
94
|
+
update: (model, message) => message.type === 'Input'
|
|
95
|
+
? pagination.receive(model, message.props)
|
|
96
|
+
: pagination.update(model, message),
|
|
97
|
+
});
|
|
98
|
+
const receive = (props) => source.send({ type: 'Input', props });
|
|
99
|
+
receive(props);
|
|
100
|
+
return { ...source, send: source.send, receive };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
86
103
|
}
|
package/dist/program.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Snapshot } from './snapshot.js';
|
|
1
2
|
import { Cause, Effect, Stream } from 'effect';
|
|
2
3
|
export type Send<Message> = (message: Message) => void;
|
|
3
4
|
/** A slot owns either one completion or a stream of progress/events, canceled together. */
|
|
@@ -30,9 +31,9 @@ export interface Transition<Model, Message, R = never> {
|
|
|
30
31
|
readonly cancel?: readonly string[];
|
|
31
32
|
}
|
|
32
33
|
export interface Program<Model, Message> {
|
|
33
|
-
readonly model: () => Model
|
|
34
|
+
readonly model: () => Snapshot<Model>;
|
|
34
35
|
readonly send: Send<Message>;
|
|
35
|
-
readonly subscribe: (listener: (model: Model) => void) => () => void;
|
|
36
|
+
readonly subscribe: (listener: (model: Snapshot<Model>) => void) => () => void;
|
|
36
37
|
readonly dispose: () => void;
|
|
37
38
|
}
|
|
38
39
|
export interface RunningProgram<Model, Message> extends Program<Model, Message> {
|
|
@@ -43,6 +44,7 @@ export interface RunningProgram<Model, Message> extends Program<Model, Message>
|
|
|
43
44
|
export declare function program<Model, Message>(options: {
|
|
44
45
|
initial: Model;
|
|
45
46
|
name?: string;
|
|
46
|
-
|
|
47
|
+
checkSnapshots?: boolean;
|
|
48
|
+
update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message>;
|
|
47
49
|
onDefect?: (cause: unknown) => void;
|
|
48
50
|
}): RunningProgram<Model, Message>;
|
package/dist/program.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { protectSnapshot, checkSnapshotsByDefault } from './snapshot.js';
|
|
1
2
|
import { reportError, reportSafely } from './errors.js';
|
|
2
3
|
import { traceProgram, nextProgramId, hasProgramObservers } from './diagnostics.js';
|
|
3
4
|
import { Cause, Effect, Fiber, Option, Stream } from 'effect';
|
|
@@ -20,6 +21,7 @@ export function mapCommand(command, map) {
|
|
|
20
21
|
}
|
|
21
22
|
/** One immutable source of truth. Reducers are synchronous; commands own async work. */
|
|
22
23
|
export function program(options) {
|
|
24
|
+
const checkSnapshots = options.checkSnapshots ?? checkSnapshotsByDefault;
|
|
23
25
|
const id = nextProgramId();
|
|
24
26
|
const trace = (kind, slot, message) => hasProgramObservers() &&
|
|
25
27
|
traceProgram({
|
|
@@ -45,7 +47,7 @@ export function program(options) {
|
|
|
45
47
|
}
|
|
46
48
|
};
|
|
47
49
|
const registry = AtomRegistry.make();
|
|
48
|
-
const atom = Atom.make(options.initial);
|
|
50
|
+
const atom = Atom.make(protectSnapshot(options.initial, checkSnapshots));
|
|
49
51
|
const release = registry.mount(atom);
|
|
50
52
|
const running = new Map();
|
|
51
53
|
let disposed = false;
|
|
@@ -73,7 +75,7 @@ export function program(options) {
|
|
|
73
75
|
trace('update', undefined, message);
|
|
74
76
|
for (const slot of transition.cancel ?? [])
|
|
75
77
|
cancel(slot);
|
|
76
|
-
registry.set(atom, transition.model);
|
|
78
|
+
registry.set(atom, protectSnapshot(transition.model, checkSnapshots));
|
|
77
79
|
for (const command of transition.commands ?? []) {
|
|
78
80
|
if (disposed)
|
|
79
81
|
break;
|
package/dist/query.d.ts
CHANGED
|
@@ -7,16 +7,20 @@ export interface Query<Args, A, E = never, R = never> {
|
|
|
7
7
|
readonly load: (args: Args) => Effect.Effect<A, E, R>;
|
|
8
8
|
/** Revalidate on activation/prefetch after this many milliseconds. No polling timer. */
|
|
9
9
|
readonly staleTime: number;
|
|
10
|
+
/** Reuse unchanged domain entities before publishing a refreshed result. */
|
|
11
|
+
readonly share?: (previous: A, next: A) => A;
|
|
10
12
|
}
|
|
11
|
-
/** Identity belongs to arguments, never callback identity.
|
|
13
|
+
/** Identity belongs to arguments, never callback identity. Separate caches isolate independent data scopes. */
|
|
12
14
|
export declare function query<A, E = never, R = never>(definition: {
|
|
13
15
|
readonly name: string;
|
|
14
16
|
readonly load: () => Effect.Effect<A, E, R>;
|
|
15
17
|
readonly staleTime?: number;
|
|
18
|
+
readonly share?: (previous: A, next: A) => A;
|
|
16
19
|
}): Query<true, A, E, R>;
|
|
17
20
|
export declare function query<Args, A, E = never, R = never>(definition: {
|
|
18
21
|
readonly name: string;
|
|
19
22
|
readonly key: (args: Args) => string;
|
|
20
23
|
readonly load: (args: Args) => Effect.Effect<A, E, R>;
|
|
21
24
|
readonly staleTime?: number;
|
|
25
|
+
readonly share?: (previous: A, next: A) => A;
|
|
22
26
|
}): Query<Args, A, E, R>;
|
package/dist/resource.js
CHANGED
|
@@ -5,7 +5,12 @@ import { component } from './component.js';
|
|
|
5
5
|
import { defaultUiRuntime } from './runtime.js';
|
|
6
6
|
import { effectCommand } from './program.js';
|
|
7
7
|
export const available = (result) => Option.getOrUndefined(AsyncResult.value(result));
|
|
8
|
-
export const resourceError = (result) =>
|
|
8
|
+
export const resourceError = (result) => {
|
|
9
|
+
if (!AsyncResult.isFailure(result))
|
|
10
|
+
return '';
|
|
11
|
+
const error = Cause.squash(result.cause);
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
};
|
|
9
14
|
/** A replaceable request owns one result. Key changes clear it; refreshes preserve the last success. */
|
|
10
15
|
export function resourceComponent(definition) {
|
|
11
16
|
const runtime = definition.runtime ?? defaultUiRuntime;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Snapshot } from './snapshot.js';
|
|
1
2
|
import { Context, Effect } from 'effect';
|
|
2
3
|
import { type Command, type RunningProgram, type Transition } from './program.js';
|
|
3
4
|
/** A binding to application-owned services, not a new scope or service lifetime. */
|
|
@@ -7,8 +8,9 @@ export interface UiRuntime<R> {
|
|
|
7
8
|
readonly command: <M>(command: Command<M, R>) => Command<M>;
|
|
8
9
|
readonly program: <M, Msg>(options: {
|
|
9
10
|
initial: M;
|
|
10
|
-
update: (model: M
|
|
11
|
+
update: (model: Snapshot<M>, message: Msg) => Transition<M, Msg, R>;
|
|
11
12
|
name?: string;
|
|
13
|
+
checkSnapshots?: boolean;
|
|
12
14
|
onDefect?: (cause: unknown) => void;
|
|
13
15
|
}) => RunningProgram<M, Msg>;
|
|
14
16
|
}
|
package/dist/session.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Query } from './query.js';
|
|
2
|
+
import type { DisposableOwner } from './owner.js';
|
|
2
3
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
/** Explicit ownership for the application's DOM and Telegram adapters. */
|
|
4
|
+
import type { QueryCache } from './cache.js';
|
|
5
|
+
/** Own subscriptions, DOM adapters and other cleanup for one lifetime. */
|
|
6
6
|
export declare function lifetime(): {
|
|
7
7
|
add(cleanup: () => void): void;
|
|
8
8
|
dispose(): void;
|
|
@@ -10,38 +10,29 @@ export declare function lifetime(): {
|
|
|
10
10
|
};
|
|
11
11
|
export type Read<A> = () => A;
|
|
12
12
|
export interface SessionContext<R = never> {
|
|
13
|
-
readonly cache:
|
|
13
|
+
readonly cache: QueryCache<R>;
|
|
14
14
|
readonly changed: () => void;
|
|
15
15
|
}
|
|
16
|
-
/** Explicit request selection. Reads are Effect values; no tracking context or reactive props. */
|
|
17
|
-
export declare function resource<A>(context: SessionContext, namespace?: string): {
|
|
18
|
-
select(selected: string | undefined, load: () => UiLoad<A>): void;
|
|
19
|
-
read: () => AsyncResult.AsyncResult<A, unknown>;
|
|
20
|
-
refresh: () => void;
|
|
21
|
-
dispose: () => void;
|
|
22
|
-
};
|
|
23
16
|
/**
|
|
24
17
|
* Idempotent query reconciliation for snapshot ticks. Freshness is checked when entering a
|
|
25
|
-
* selection (including remount), not on every same-key tick; refresh
|
|
18
|
+
* selection (including remount), not on every same-key tick; refresh revalidates unless a request is already in flight.
|
|
26
19
|
*/
|
|
27
|
-
export declare function queryResource<Args, A, E, R>(context:
|
|
20
|
+
export declare function queryResource<Args, A, E, R>(context: {
|
|
21
|
+
cache: QueryCache<R>;
|
|
22
|
+
changed?: () => void;
|
|
23
|
+
}, definition: Query<Args, A, E, NoInfer<R>>): {
|
|
28
24
|
select(args: Args | undefined): void;
|
|
29
25
|
read: () => AsyncResult.AsyncResult<A, E>;
|
|
26
|
+
subscribe(listener: (result: AsyncResult.AsyncResult<A, E>) => void): () => void;
|
|
30
27
|
refresh: () => void;
|
|
31
28
|
dispose: () => void;
|
|
32
29
|
};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
loadingMore: boolean;
|
|
40
|
-
hasMore: boolean;
|
|
41
|
-
error: string;
|
|
42
|
-
};
|
|
43
|
-
more: () => void | undefined;
|
|
44
|
-
refresh: () => void | undefined;
|
|
30
|
+
/** Publish Effect results directly; the owner releases the subscription on disposal. */
|
|
31
|
+
export declare function observeQuery<Args, A, E, R>(owner: DisposableOwner, cache: QueryCache<R>, definition: Query<Args, A, E, NoInfer<R>>, changed: (result: AsyncResult.AsyncResult<A, E>) => void): {
|
|
32
|
+
select(args: Args | undefined): void;
|
|
33
|
+
read: () => AsyncResult.AsyncResult<A, E>;
|
|
34
|
+
subscribe(listener: (result: AsyncResult.AsyncResult<A, E>) => void): () => void;
|
|
35
|
+
refresh: () => void;
|
|
45
36
|
dispose: () => void;
|
|
46
37
|
};
|
|
47
38
|
/** Application selectors run once per explicit input version, with optional prior output sharing. */
|
package/dist/session.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { runAll } from './errors.js';
|
|
2
|
-
import { Cause, Option } from 'effect';
|
|
1
|
+
import { runAll, reportError, reportSafely } from './errors.js';
|
|
3
2
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
4
3
|
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
5
|
-
|
|
6
|
-
/** Explicit ownership for the application's DOM and Telegram adapters. */
|
|
4
|
+
/** Own subscriptions, DOM adapters and other cleanup for one lifetime. */
|
|
7
5
|
export function lifetime() {
|
|
8
6
|
const cleanups = [];
|
|
9
7
|
let disposed = false;
|
|
@@ -25,47 +23,9 @@ export function lifetime() {
|
|
|
25
23
|
},
|
|
26
24
|
};
|
|
27
25
|
}
|
|
28
|
-
/** Explicit request selection. Reads are Effect values; no tracking context or reactive props. */
|
|
29
|
-
export function resource(context, namespace) {
|
|
30
|
-
let key, atom;
|
|
31
|
-
let stop;
|
|
32
|
-
const initial = AsyncResult.initial();
|
|
33
|
-
return {
|
|
34
|
-
select(selected, load) {
|
|
35
|
-
const next = selected === undefined
|
|
36
|
-
? undefined
|
|
37
|
-
: `${context.cache.registry.get(context.cache.generation)}:${selected}`;
|
|
38
|
-
if (next === key)
|
|
39
|
-
return;
|
|
40
|
-
stop?.();
|
|
41
|
-
stop = undefined;
|
|
42
|
-
key = next;
|
|
43
|
-
atom =
|
|
44
|
-
next === undefined
|
|
45
|
-
? undefined
|
|
46
|
-
: namespace
|
|
47
|
-
? context.cache.resource(`${namespace}:${selected}`, load)
|
|
48
|
-
: Atom.make(loadEffect(load)).pipe(Atom.setIdleTTL(0));
|
|
49
|
-
if (atom)
|
|
50
|
-
stop = context.cache.registry.subscribe(atom, context.changed, { immediate: true });
|
|
51
|
-
context.changed();
|
|
52
|
-
},
|
|
53
|
-
read: () => (atom ? context.cache.registry.get(atom) : initial),
|
|
54
|
-
refresh: () => {
|
|
55
|
-
if (atom)
|
|
56
|
-
context.cache.registry.refresh(atom);
|
|
57
|
-
},
|
|
58
|
-
dispose: () => {
|
|
59
|
-
stop?.();
|
|
60
|
-
stop = undefined;
|
|
61
|
-
atom = undefined;
|
|
62
|
-
key = undefined;
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
26
|
/**
|
|
67
27
|
* Idempotent query reconciliation for snapshot ticks. Freshness is checked when entering a
|
|
68
|
-
* selection (including remount), not on every same-key tick; refresh
|
|
28
|
+
* selection (including remount), not on every same-key tick; refresh revalidates unless a request is already in flight.
|
|
69
29
|
*/
|
|
70
30
|
export function queryResource(context, definition) {
|
|
71
31
|
let key;
|
|
@@ -73,7 +33,71 @@ export function queryResource(context, definition) {
|
|
|
73
33
|
let atom;
|
|
74
34
|
let stop;
|
|
75
35
|
let disposed = false;
|
|
36
|
+
let revision = 0;
|
|
76
37
|
const initial = AsyncResult.initial();
|
|
38
|
+
const listeners = new Set();
|
|
39
|
+
const read = () => atom && !disposed && generation === context.cache.registry.get(context.cache.generation)
|
|
40
|
+
? context.cache.registry.get(atom)
|
|
41
|
+
: initial;
|
|
42
|
+
let published;
|
|
43
|
+
let notifying = false;
|
|
44
|
+
let pending = false;
|
|
45
|
+
const call = (work) => {
|
|
46
|
+
try {
|
|
47
|
+
work();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
reportSafely(reportError, error);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const notify = () => {
|
|
54
|
+
if (disposed)
|
|
55
|
+
return;
|
|
56
|
+
pending = true;
|
|
57
|
+
if (notifying)
|
|
58
|
+
return;
|
|
59
|
+
notifying = true;
|
|
60
|
+
try {
|
|
61
|
+
while (pending && !disposed) {
|
|
62
|
+
pending = false;
|
|
63
|
+
if (context.changed)
|
|
64
|
+
call(context.changed);
|
|
65
|
+
if (disposed)
|
|
66
|
+
break;
|
|
67
|
+
const result = read();
|
|
68
|
+
if (result === published)
|
|
69
|
+
continue;
|
|
70
|
+
published = result;
|
|
71
|
+
const selected = revision;
|
|
72
|
+
// A callback may select, reset, refresh or dispose. Finish only the current publication.
|
|
73
|
+
// oxlint-disable-next-line unicorn/no-useless-spread
|
|
74
|
+
for (const listener of [...listeners]) {
|
|
75
|
+
if (disposed || selected !== revision || result !== read())
|
|
76
|
+
break;
|
|
77
|
+
if (listeners.has(listener))
|
|
78
|
+
call(() => listener(result));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
notifying = false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const disconnect = () => {
|
|
87
|
+
const release = stop;
|
|
88
|
+
stop = undefined;
|
|
89
|
+
release?.();
|
|
90
|
+
};
|
|
91
|
+
// Initialize before subscribing; construction must not invoke application callbacks.
|
|
92
|
+
context.cache.registry.get(context.cache.generation);
|
|
93
|
+
const stopGeneration = context.cache.registry.subscribe(context.cache.generation, () => {
|
|
94
|
+
revision++;
|
|
95
|
+
disconnect();
|
|
96
|
+
atom = undefined;
|
|
97
|
+
key = undefined;
|
|
98
|
+
generation = -1;
|
|
99
|
+
notify();
|
|
100
|
+
});
|
|
77
101
|
return {
|
|
78
102
|
select(args) {
|
|
79
103
|
if (disposed)
|
|
@@ -82,71 +106,59 @@ export function queryResource(context, definition) {
|
|
|
82
106
|
const nextKey = args === undefined ? undefined : definition.key(args);
|
|
83
107
|
if (nextKey === key && nextGeneration === generation)
|
|
84
108
|
return;
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
const selected = ++revision;
|
|
110
|
+
disconnect();
|
|
87
111
|
key = nextKey;
|
|
88
112
|
generation = nextGeneration;
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
113
|
+
const nextAtom = args === undefined ? undefined : context.cache.query(definition, args);
|
|
114
|
+
if (disposed || selected !== revision)
|
|
115
|
+
return;
|
|
116
|
+
atom = nextAtom;
|
|
117
|
+
if (nextAtom) {
|
|
118
|
+
// Subscription setup itself can execute a synchronous Effect and reenter selection.
|
|
119
|
+
const release = context.cache.registry.subscribe(nextAtom, notify);
|
|
120
|
+
if (disposed || selected !== revision) {
|
|
121
|
+
release();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
stop = release;
|
|
125
|
+
}
|
|
126
|
+
notify();
|
|
127
|
+
},
|
|
128
|
+
read,
|
|
129
|
+
subscribe(listener) {
|
|
130
|
+
if (disposed)
|
|
131
|
+
return () => { };
|
|
132
|
+
listeners.add(listener);
|
|
133
|
+
return () => {
|
|
134
|
+
listeners.delete(listener);
|
|
135
|
+
};
|
|
93
136
|
},
|
|
94
|
-
read: () => atom && !disposed && generation === context.cache.registry.get(context.cache.generation)
|
|
95
|
-
? context.cache.registry.get(atom)
|
|
96
|
-
: initial,
|
|
97
137
|
refresh: () => {
|
|
98
|
-
if (atom &&
|
|
138
|
+
if (atom &&
|
|
139
|
+
!disposed &&
|
|
140
|
+
generation === context.cache.registry.get(context.cache.generation) &&
|
|
141
|
+
!context.cache.registry.get(atom).waiting)
|
|
99
142
|
context.cache.registry.refresh(atom);
|
|
100
143
|
},
|
|
101
144
|
dispose: () => {
|
|
145
|
+
if (disposed)
|
|
146
|
+
return;
|
|
102
147
|
disposed = true;
|
|
103
|
-
|
|
104
|
-
|
|
148
|
+
revision++;
|
|
149
|
+
listeners.clear();
|
|
150
|
+
stopGeneration();
|
|
151
|
+
disconnect();
|
|
105
152
|
atom = undefined;
|
|
106
153
|
key = undefined;
|
|
107
154
|
},
|
|
108
155
|
};
|
|
109
156
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
return
|
|
115
|
-
select(selected, load) {
|
|
116
|
-
const next = selected === undefined
|
|
117
|
-
? undefined
|
|
118
|
-
: `${context.cache.registry.get(context.cache.generation)}:${selected}`;
|
|
119
|
-
if (next === key)
|
|
120
|
-
return;
|
|
121
|
-
stop?.();
|
|
122
|
-
stop = undefined;
|
|
123
|
-
key = next;
|
|
124
|
-
page =
|
|
125
|
-
next === undefined ? undefined : makePagedResource(context.cache.registry, load, itemKey);
|
|
126
|
-
if (page)
|
|
127
|
-
stop = context.cache.registry.subscribe(page.atom, context.changed, { immediate: true });
|
|
128
|
-
context.changed();
|
|
129
|
-
},
|
|
130
|
-
snapshot() {
|
|
131
|
-
const result = state(), value = Option.getOrUndefined(AsyncResult.value(result));
|
|
132
|
-
return {
|
|
133
|
-
items: value?.items ?? [],
|
|
134
|
-
totalCount: value?.totalCount,
|
|
135
|
-
loading: result.waiting && !value,
|
|
136
|
-
loadingMore: result.waiting && Boolean(value),
|
|
137
|
-
hasMore: value?.next !== undefined,
|
|
138
|
-
error: AsyncResult.isFailure(result) ? String(Cause.squash(result.cause)) : '',
|
|
139
|
-
};
|
|
140
|
-
},
|
|
141
|
-
more: () => page?.more(),
|
|
142
|
-
refresh: () => page?.refresh(),
|
|
143
|
-
dispose: () => {
|
|
144
|
-
stop?.();
|
|
145
|
-
stop = undefined;
|
|
146
|
-
page = undefined;
|
|
147
|
-
key = undefined;
|
|
148
|
-
},
|
|
149
|
-
};
|
|
157
|
+
/** Publish Effect results directly; the owner releases the subscription on disposal. */
|
|
158
|
+
export function observeQuery(owner, cache, definition, changed) {
|
|
159
|
+
const resource = queryResource({ cache }, definition);
|
|
160
|
+
resource.subscribe(changed);
|
|
161
|
+
return owner.own(resource);
|
|
150
162
|
}
|
|
151
163
|
/** Application selectors run once per explicit input version, with optional prior output sharing. */
|
|
152
164
|
export function projectionCache() {
|
package/dist/share.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
+
/** Override sharing for selected fields, for example a collection keyed by domain identity. */
|
|
2
|
+
export type ShareFields<T> = {
|
|
3
|
+
readonly [K in keyof T]?: (previous: T[K], next: T[K]) => T[K];
|
|
4
|
+
};
|
|
1
5
|
/** Share JSON-shaped data; Blob, typed arrays and class instances retain their own identity. */
|
|
2
|
-
export declare function shareValue<T>(previous: T, next: T): T;
|
|
6
|
+
export declare function shareValue<T>(previous: T, next: T, fields?: ShareFields<T>): T;
|
package/dist/share.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// already compared object pair without retaining either snapshot after its owners release it.
|
|
3
3
|
const sharedPairs = new WeakMap();
|
|
4
4
|
/** Share JSON-shaped data; Blob, typed arrays and class instances retain their own identity. */
|
|
5
|
-
export function shareValue(previous, next) {
|
|
5
|
+
export function shareValue(previous, next, fields) {
|
|
6
6
|
if (Object.is(previous, next))
|
|
7
7
|
return previous;
|
|
8
8
|
if (!previous || !next || typeof previous !== 'object' || typeof next !== 'object')
|
|
@@ -15,7 +15,7 @@ export function shareValue(previous, next) {
|
|
|
15
15
|
Object.getPrototypeOf(previous) !== Object.prototype))
|
|
16
16
|
return next;
|
|
17
17
|
let pairs = sharedPairs.get(previous);
|
|
18
|
-
if (pairs?.has(next))
|
|
18
|
+
if (!fields && pairs?.has(next))
|
|
19
19
|
return pairs.get(next);
|
|
20
20
|
const old = previous;
|
|
21
21
|
const value = next;
|
|
@@ -24,13 +24,19 @@ export function shareValue(previous, next) {
|
|
|
24
24
|
let unchangedNext = true;
|
|
25
25
|
const shared = array ? [] : {};
|
|
26
26
|
for (const key of keys) {
|
|
27
|
-
|
|
27
|
+
const custom = fields && Object.hasOwn(fields, key) ? fields[key] : undefined;
|
|
28
|
+
shared[key] =
|
|
29
|
+
custom && Object.hasOwn(old, key)
|
|
30
|
+
? custom(old[key], value[key])
|
|
31
|
+
: shareValue(old[key], value[key]);
|
|
28
32
|
if (!Object.hasOwn(old, key) || shared[key] !== old[key])
|
|
29
33
|
equal = false;
|
|
30
34
|
if (shared[key] !== value[key])
|
|
31
35
|
unchangedNext = false;
|
|
32
36
|
}
|
|
33
37
|
const result = equal ? previous : unchangedNext ? next : shared;
|
|
38
|
+
if (fields)
|
|
39
|
+
return result;
|
|
34
40
|
if (!pairs) {
|
|
35
41
|
pairs = new WeakMap();
|
|
36
42
|
sharedPairs.set(previous, pairs);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Published fields cannot be reassigned. Nested domain types should also declare readonly data. */
|
|
2
|
+
export type Snapshot<T> = Readonly<T>;
|
|
3
|
+
export declare const checkSnapshotsByDefault: boolean;
|
|
4
|
+
/** Development guard for immutable plain data. Opaque objects keep their own lifecycle. */
|
|
5
|
+
export declare function protectSnapshot<T>(value: T, enabled: boolean): T;
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isAsyncResult } from 'effect/unstable/reactivity/AsyncResult';
|
|
2
|
+
export const checkSnapshotsByDefault = typeof __EFFECTWEB_DEV__ !== 'undefined' && __EFFECTWEB_DEV__;
|
|
3
|
+
const checked = new WeakSet();
|
|
4
|
+
/** Development guard for immutable plain data. Opaque objects keep their own lifecycle. */
|
|
5
|
+
export function protectSnapshot(value, enabled) {
|
|
6
|
+
if (!enabled || !value || typeof value !== 'object' || checked.has(value))
|
|
7
|
+
return value;
|
|
8
|
+
// AsyncResult is an immutable Effect wrapper, but its successful UI data is plain data.
|
|
9
|
+
if (isAsyncResult(value)) {
|
|
10
|
+
checked.add(value);
|
|
11
|
+
if (value._tag === 'Success')
|
|
12
|
+
protectSnapshot(value.value, true);
|
|
13
|
+
else if (value._tag === 'Failure' && value.previousSuccess._tag === 'Some')
|
|
14
|
+
protectSnapshot(value.previousSuccess.value, true);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
const prototype = Object.getPrototypeOf(value);
|
|
18
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)
|
|
19
|
+
return value;
|
|
20
|
+
// Mark before walking to allow cyclic data and reuse already checked shared branches.
|
|
21
|
+
checked.add(value);
|
|
22
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
23
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
24
|
+
const descriptor = Reflect.get(descriptors, key);
|
|
25
|
+
// Never execute getters or traverse closures, class instances, Effects or DOM objects.
|
|
26
|
+
if ('value' in descriptor)
|
|
27
|
+
protectSnapshot(descriptor.value, true);
|
|
28
|
+
}
|
|
29
|
+
Object.freeze(value);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
package/dist/tasks.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ModelOwner, TaskPolicy } from './owner.js';
|
|
1
2
|
import { Effect } from 'effect';
|
|
2
3
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
3
4
|
import type { View } from './dom.js';
|
|
@@ -44,6 +45,15 @@ type Init<Props, State> = {
|
|
|
44
45
|
identity?: (props: Props) => unknown;
|
|
45
46
|
name?: string;
|
|
46
47
|
};
|
|
48
|
+
interface ControllerTask<R> {
|
|
49
|
+
readonly run: (...args: never[]) => Effect.Effect<unknown, unknown, R>;
|
|
50
|
+
readonly policy: TaskPolicy;
|
|
51
|
+
/** Actions sharing a slot share cancellation and concurrency rules. Defaults to the action name. */
|
|
52
|
+
readonly slot?: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function defineTasks<Owner extends Pick<ModelOwner<object>, 'run'>, T extends Record<string, ControllerTask<Effect.Services<Parameters<Owner['run']>[1]>>>>(owner: Owner, definitions: T): {
|
|
55
|
+
readonly [K in keyof T]: (...args: Parameters<T[K]['run']>) => void;
|
|
56
|
+
};
|
|
47
57
|
export declare function defineTasks<Props, State extends object, R>(definition: Init<Props, State> & {
|
|
48
58
|
runtime: UiRuntime<R>;
|
|
49
59
|
}): ReturnType<typeof taskBuilder<Props, State, R>>;
|
package/dist/tasks.js
CHANGED
|
@@ -4,7 +4,14 @@ import { programView } from './component.js';
|
|
|
4
4
|
import { effectCommand, program } from './program.js';
|
|
5
5
|
import { defaultUiRuntime } from './runtime.js';
|
|
6
6
|
import { patchModel } from './state.js';
|
|
7
|
-
export function defineTasks(definition) {
|
|
7
|
+
export function defineTasks(definition, definitions) {
|
|
8
|
+
if ('run' in definition) {
|
|
9
|
+
const actions = Object.create(null);
|
|
10
|
+
for (const [name, task] of Object.entries(definitions)) {
|
|
11
|
+
actions[name] = (...args) => definition.run(task.slot ?? name, Effect.suspend(() => task.run(...args)), task.policy);
|
|
12
|
+
}
|
|
13
|
+
return actions;
|
|
14
|
+
}
|
|
8
15
|
return taskBuilder(definition, definition.runtime ?? defaultUiRuntime);
|
|
9
16
|
}
|
|
10
17
|
function stopWaiting(result) {
|
package/dist/testing.d.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { type UiRuntime } from './runtime.js';
|
|
|
5
5
|
export declare function programDriver<M, Msg>(source: RunningProgram<M, Msg>): ReturnType<typeof makeDriver<M, Msg, never>>;
|
|
6
6
|
export declare function programDriver<M, Msg, R>(source: RunningProgram<M, Msg>, runtime: UiRuntime<R>): ReturnType<typeof makeDriver<M, Msg, R>>;
|
|
7
7
|
declare function makeDriver<M, Msg, R>(source: RunningProgram<M, Msg>, runtime: UiRuntime<R>): {
|
|
8
|
-
model: () => M
|
|
8
|
+
model: () => Readonly<M>;
|
|
9
9
|
send: import("./program.js").Send<Msg>;
|
|
10
|
-
subscribe: (listener: (model: M) => void) => () => void;
|
|
10
|
+
subscribe: (listener: (model: Readonly<M>) => void) => () => void;
|
|
11
11
|
dispose: () => void;
|
|
12
12
|
activeSlots: () => readonly string[];
|
|
13
13
|
awaitSlot: (slot: string) => Promise<void>;
|