effectweb 0.2.3 → 0.3.1

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.
Files changed (77) hide show
  1. package/README.md +93 -1
  2. package/dist/AsyncContent.d.ts +2 -2
  3. package/dist/AsyncContent.js +7 -4
  4. package/dist/actions.d.ts +4 -3
  5. package/dist/actions.js +1 -0
  6. package/dist/boundary.d.ts +11 -0
  7. package/dist/boundary.js +66 -0
  8. package/dist/cache-internals.d.ts +18 -0
  9. package/dist/cache-internals.js +11 -0
  10. package/dist/cache.d.ts +35 -16
  11. package/dist/cache.js +258 -37
  12. package/dist/collection.d.ts +10 -9
  13. package/dist/collection.js +32 -6
  14. package/dist/component.d.ts +11 -7
  15. package/dist/component.js +70 -10
  16. package/dist/diagnostics.d.ts +25 -0
  17. package/dist/diagnostics.js +167 -1
  18. package/dist/dom-attributes.json +71 -0
  19. package/dist/dom.d.ts +44 -15
  20. package/dist/dom.js +493 -89
  21. package/dist/effectEvent.d.ts +2 -1
  22. package/dist/effectEvent.js +4 -1
  23. package/dist/form.d.ts +61 -0
  24. package/dist/form.js +98 -0
  25. package/dist/html-attributes.d.ts +602 -0
  26. package/dist/html-attributes.js +1 -0
  27. package/dist/http.d.ts +5 -0
  28. package/dist/http.js +7 -0
  29. package/dist/index.d.ts +16 -11
  30. package/dist/index.js +11 -6
  31. package/dist/infinite-query.d.ts +42 -0
  32. package/dist/infinite-query.js +150 -0
  33. package/dist/intrinsic.d.ts +2 -0
  34. package/dist/intrinsic.js +120 -0
  35. package/dist/jsx.d.ts +165 -33
  36. package/dist/jsx.js +2 -1
  37. package/dist/keyed-tasks.d.ts +29 -0
  38. package/dist/keyed-tasks.js +97 -0
  39. package/dist/lazy.d.ts +23 -0
  40. package/dist/lazy.js +66 -0
  41. package/dist/load.d.ts +3 -3
  42. package/dist/mount.d.ts +11 -12
  43. package/dist/mount.js +30 -14
  44. package/dist/owner.d.ts +14 -10
  45. package/dist/owner.js +66 -39
  46. package/dist/pages.d.ts +28 -26
  47. package/dist/pages.js +19 -11
  48. package/dist/program.d.ts +29 -9
  49. package/dist/program.js +204 -61
  50. package/dist/query-internals.d.ts +13 -0
  51. package/dist/query-internals.js +10 -0
  52. package/dist/query.d.ts +30 -19
  53. package/dist/query.js +64 -5
  54. package/dist/resource.d.ts +4 -10
  55. package/dist/resource.js +14 -5
  56. package/dist/runtime.d.ts +1 -2
  57. package/dist/runtime.js +3 -3
  58. package/dist/session.d.ts +12 -15
  59. package/dist/session.js +25 -13
  60. package/dist/settlement.d.ts +7 -0
  61. package/dist/settlement.js +27 -0
  62. package/dist/share.d.ts +5 -4
  63. package/dist/share.js +5 -44
  64. package/dist/sharing.d.ts +6 -0
  65. package/dist/sharing.js +96 -0
  66. package/dist/slotIdentity.d.ts +1 -0
  67. package/dist/slotIdentity.js +2 -0
  68. package/dist/snapshot.d.ts +16 -5
  69. package/dist/snapshot.js +34 -27
  70. package/dist/svg-attributes.d.ts +12 -0
  71. package/dist/svg-attributes.js +1 -0
  72. package/dist/task.d.ts +9 -8
  73. package/dist/task.js +75 -47
  74. package/dist/tasks.d.ts +20 -15
  75. package/dist/tasks.js +40 -25
  76. package/dist/testing.d.ts +10 -14
  77. package/package.json +13 -1
@@ -0,0 +1,97 @@
1
+ import { Cause, Effect, Exit } from 'effect';
2
+ import { commandSlot, program } from './program.js';
3
+ import { defaultUiRuntime } from './runtime.js';
4
+ import { reportError, reportSafely } from './errors.js';
5
+ import { protectSnapshot } from './snapshot.js';
6
+ /** Per-entity serialization. Inputs are captured now; run reads services when work starts. */
7
+ export function keyedTasks(owner, definition, ...provided) {
8
+ const runtime = provided[0] ?? defaultUiRuntime;
9
+ const entries = new Map();
10
+ let disposed = false;
11
+ const slot = commandSlot(definition.name);
12
+ const makeSource = () => program({
13
+ initial: null,
14
+ update: (model, message) => 'command' in message ? { model, commands: [message.command] } : { model, cancel: [slot] },
15
+ });
16
+ // Each key owns a program, including its interrupted fibers, so drain never waits on other keys.
17
+ const get = (key) => {
18
+ let entry = entries.get(key);
19
+ if (!entry) {
20
+ entry = { source: makeSource(), version: 0, retiring: false };
21
+ entries.set(key, entry);
22
+ }
23
+ return entry;
24
+ };
25
+ const dispose = () => {
26
+ disposed = true;
27
+ for (const entry of entries.values())
28
+ entry.source.dispose();
29
+ };
30
+ const close = async () => {
31
+ dispose();
32
+ await Promise.all([...entries.values()].map((entry) => entry.source.close()));
33
+ entries.clear();
34
+ };
35
+ owner.own({ dispose, close });
36
+ return {
37
+ submit(key, input) {
38
+ if (disposed || owner.disposed)
39
+ return { outcome: Promise.resolve(Object.freeze({ _tag: 'Cancelled' })) };
40
+ const captured = protectSnapshot(input);
41
+ const entry = get(key);
42
+ entry.version++;
43
+ let settle;
44
+ const outcome = new Promise((resolve) => {
45
+ settle = (result) => resolve(protectSnapshot(result));
46
+ });
47
+ outcome
48
+ .then(async () => {
49
+ if (entry.retiring)
50
+ return;
51
+ entry.retiring = true;
52
+ let version;
53
+ do {
54
+ version = entry.version;
55
+ await entry.source.awaitStopped();
56
+ } while (version !== entry.version);
57
+ if (entries.get(key) === entry) {
58
+ entries.delete(key);
59
+ entry.source.dispose();
60
+ }
61
+ })
62
+ .catch((error) => reportSafely(reportError, error));
63
+ const action = Effect.uninterruptibleMask((restore) => restore(Effect.suspend(() => definition.run(key, captured)).pipe(Effect.map((value) => protectSnapshot(value)))).pipe(Effect.exit, Effect.flatMap((exit) => Effect.sync(() => settle(Exit.isSuccess(exit)
64
+ ? { _tag: 'Success', value: exit.value }
65
+ : Cause.hasInterruptsOnly(exit.cause)
66
+ ? { _tag: 'Cancelled' }
67
+ : { _tag: 'Failure', cause: exit.cause })))));
68
+ entry.source.send({
69
+ command: {
70
+ slot,
71
+ policy: definition.policy,
72
+ action: runtime.provide(action),
73
+ onDiscard: (_tag) => settle({ _tag }),
74
+ },
75
+ });
76
+ return { outcome };
77
+ },
78
+ cancel(key) {
79
+ const entry = entries.get(key);
80
+ if (entry) {
81
+ entry.version++;
82
+ entry.source.send({ cancel: true });
83
+ }
84
+ },
85
+ async drain(...selected) {
86
+ const pending = selected.length
87
+ ? [entries.get(selected[0])].filter((entry) => !!entry)
88
+ : [...entries.values()];
89
+ await Promise.all(pending.map(async (entry) => {
90
+ await entry.source.awaitIdle();
91
+ await entry.source.awaitStopped();
92
+ }));
93
+ },
94
+ close,
95
+ dispose,
96
+ };
97
+ }
package/dist/lazy.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { Effect, type Cause } from 'effect';
2
+ import { type View } from './dom.js';
3
+ import { type UiRuntime } from './runtime.js';
4
+ export interface LazyViewOptions<Model, Message, E> {
5
+ readonly pending?: View<Model, Message>;
6
+ readonly failure?: View<{
7
+ readonly model: Model;
8
+ readonly cause: Cause.Cause<E>;
9
+ }, Message>;
10
+ }
11
+ /**
12
+ * Mount-owned loading with current inputs and a reusable successful definition.
13
+ * Unresolved placements own independent requests; unmount interrupts their work.
14
+ */
15
+ export declare function lazyView<Model, Message = never, E = never, R = never>(load: () => Effect.Effect<View<Model, Message>, E, R>, ...provided: [R] extends [never] ? [
16
+ options?: LazyViewOptions<NoInfer<Model>, NoInfer<Message>, NoInfer<E>> & {
17
+ readonly runtime?: UiRuntime<R>;
18
+ }
19
+ ] : [
20
+ options: LazyViewOptions<NoInfer<Model>, NoInfer<Message>, NoInfer<E>> & {
21
+ readonly runtime: UiRuntime<R>;
22
+ }
23
+ ]): View<Model, Message>;
package/dist/lazy.js ADDED
@@ -0,0 +1,66 @@
1
+ import { Effect, Fiber } from 'effect';
2
+ import { child, compiled, viewRegion } from './dom.js';
3
+ import { reportSafely } from './errors.js';
4
+ import { defaultUiRuntime } from './runtime.js';
5
+ /**
6
+ * Mount-owned loading with current inputs and a reusable successful definition.
7
+ * Unresolved placements own independent requests; unmount interrupts their work.
8
+ */
9
+ export function lazyView(load, ...provided) {
10
+ const options = provided[0];
11
+ const runtime = options?.runtime ?? defaultUiRuntime;
12
+ let loaded;
13
+ return compiled((scope, parent, before) => {
14
+ const render = viewRegion(scope, parent, before);
15
+ if (loaded) {
16
+ render(loaded);
17
+ return;
18
+ }
19
+ render(options?.pending);
20
+ let fiber;
21
+ const interrupt = () => {
22
+ if (fiber)
23
+ Effect.runFork(Fiber.interrupt(fiber));
24
+ };
25
+ scope.cleanups.push(interrupt);
26
+ if (scope.disposed)
27
+ return;
28
+ const finished = scope.settlement.begin();
29
+ try {
30
+ fiber = Effect.runFork(runtime.provide(Effect.suspend(load)));
31
+ fiber.addObserver(finished);
32
+ }
33
+ catch (error) {
34
+ finished();
35
+ throw error;
36
+ }
37
+ if (scope.disposed) {
38
+ interrupt();
39
+ return;
40
+ }
41
+ fiber.addObserver((exit) => {
42
+ if (scope.disposed)
43
+ return;
44
+ try {
45
+ if (exit._tag === 'Success') {
46
+ loaded = exit.value;
47
+ render(loaded);
48
+ }
49
+ else {
50
+ const failure = options?.failure;
51
+ if (failure)
52
+ render(compiled((scope, parent, before) => {
53
+ child(scope, parent, before, failure, () => [scope.value], () => ({ model: scope.value, cause: exit.cause }), scope.send);
54
+ }));
55
+ else {
56
+ render(undefined);
57
+ reportSafely(scope.report, exit.cause);
58
+ }
59
+ }
60
+ }
61
+ catch (error) {
62
+ reportSafely(scope.report, error);
63
+ }
64
+ });
65
+ });
66
+ }
package/dist/load.d.ts CHANGED
@@ -4,9 +4,9 @@ export declare const loadEffect: <A, E, R>(load: () => UiLoad<A, E, R>) => UiLoa
4
4
  /** Adapt Promise libraries at the integration boundary. Pass the signal to APIs that support it. */
5
5
  export declare const fromPromise: <A>(load: (signal: AbortSignal) => PromiseLike<A>) => UiLoad<A>;
6
6
  export interface UiPage<A, Cursor> {
7
- items: A[];
8
- totalCount?: number;
9
- next: Cursor | undefined;
7
+ readonly items: readonly A[];
8
+ readonly totalCount?: number;
9
+ readonly next: Cursor | undefined;
10
10
  }
11
11
  /** Read a local cache when executed, then acquire missing data through the same Effect contract. */
12
12
  export declare function cachedLoad<A, E, R>(read: () => A | undefined, load: () => UiLoad<A, E, R>): UiLoad<A, E, R>;
package/dist/mount.d.ts CHANGED
@@ -1,25 +1,24 @@
1
+ import type { Snapshot } from './snapshot.js';
1
2
  import { type ReportError } from './errors.js';
2
3
  import { Effect } from 'effect';
3
4
  import { type UiRuntime } from './runtime.js';
4
- import type { JSX } from './jsx.js';
5
+ export { Portal, type PortalProps } from './dom.js';
5
6
  type Work<R = never> = (() => void) | {
6
7
  dispose: () => void;
7
8
  update?: () => void;
8
9
  } | Effect.Effect<never, unknown, R>;
9
- export declare function Portal(_props: {
10
- children?: JSX.Element;
11
- }): JSX.Element;
12
- export interface DomMount {
10
+ export interface DomMount<T extends Element = Element> {
13
11
  readonly identity: unknown;
14
12
  readonly data: unknown;
15
- readonly acquire: (element: Element, input: () => unknown) => Work;
13
+ readonly acquire: (element: T, input: () => unknown) => Work;
16
14
  }
17
15
  /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
18
- export declare function domMount<T extends Element, R = never>(start: (element: T) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount;
16
+ export declare function domMount<T extends Element, R = never>(start: (element: T) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
19
17
  /** A stable DOM lifecycle with fresh immutable inputs, for focus, measurements and native events. */
20
- export declare function domBinding<T extends Element, A, R = never>(data: A, acquire: (element: T, input: () => A) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount;
21
- export declare function startMount(element: Element, mount: DomMount, report?: ReportError): {
22
- update(next: DomMount): boolean;
23
- dispose(): void;
18
+ export declare function domBinding<T extends Element, A, R = never>(data: A | Snapshot<A>, acquire: (element: T, input: () => Snapshot<A>) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
19
+ export declare function startMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError): {
20
+ closed: Promise<void>;
21
+ close(): Promise<void>;
22
+ update(next: DomMount<T>): boolean;
23
+ dispose: () => void;
24
24
  };
25
- export {};
package/dist/mount.js CHANGED
@@ -1,9 +1,7 @@
1
1
  import { reportError, reportSafely } from './errors.js';
2
2
  import { Effect, Fiber } from 'effect';
3
3
  import { defaultUiRuntime } from './runtime.js';
4
- export function Portal(_props) {
5
- throw new Error('Portal must be compiled inside a view');
6
- }
4
+ export { Portal } from './dom.js';
7
5
  /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
8
6
  export function domMount(start, ...provided) {
9
7
  const runtime = provided[0] ?? defaultUiRuntime;
@@ -33,11 +31,39 @@ export function startMount(element, mount, report = reportError) {
33
31
  const work = mount.acquire(element, () => data);
34
32
  const fiber = Effect.isEffect(work) ? Effect.runFork(work) : undefined;
35
33
  let disposed = false;
34
+ let finish;
35
+ const closed = new Promise((resolve) => {
36
+ finish = resolve;
37
+ });
36
38
  fiber?.addObserver((exit) => {
37
39
  if (exit._tag === 'Failure' && !disposed)
38
40
  reportSafely(report, exit.cause);
41
+ finish();
39
42
  });
43
+ const dispose = () => {
44
+ if (disposed)
45
+ return;
46
+ disposed = true;
47
+ if (fiber)
48
+ Effect.runFork(Fiber.interrupt(fiber));
49
+ else {
50
+ try {
51
+ if (typeof work === 'function')
52
+ work();
53
+ else if ('dispose' in work)
54
+ work.dispose();
55
+ }
56
+ finally {
57
+ finish();
58
+ }
59
+ }
60
+ };
40
61
  return {
62
+ closed,
63
+ close() {
64
+ dispose();
65
+ return closed;
66
+ },
41
67
  update(next) {
42
68
  if (next.identity !== mount.identity)
43
69
  return false;
@@ -46,16 +72,6 @@ export function startMount(element, mount, report = reportError) {
46
72
  work.update?.();
47
73
  return true;
48
74
  },
49
- dispose() {
50
- if (disposed)
51
- return;
52
- disposed = true;
53
- if (fiber)
54
- Effect.runFork(Fiber.interrupt(fiber));
55
- else if (typeof work === 'function')
56
- work();
57
- else if ('dispose' in work)
58
- work.dispose();
59
- },
75
+ dispose,
60
76
  };
61
77
  }
package/dist/owner.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
2
  import { Effect } from 'effect';
3
- import { type Program } from './program.js';
3
+ import { type Program, type TaskPolicy, type CommandSlot } from './program.js';
4
4
  import { type ReportError } from './errors.js';
5
5
  import type { UiRuntime } from './runtime.js';
6
- export type TaskPolicy = 'replace' | 'drop' | 'parallel';
6
+ export type { TaskPolicy } from './program.js';
7
7
  export interface DisposableOwner {
8
8
  readonly disposed: boolean;
9
9
  readonly own: <A extends {
@@ -13,18 +13,23 @@ export interface DisposableOwner {
13
13
  export interface ModelOwner<Model extends object, R = never> extends DisposableOwner {
14
14
  readonly source: Program<Model, never>;
15
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;
16
+ /** Expose only controller-selected editable fields to a view. */
17
+ readonly fields: <const Keys extends readonly (keyof Model)[]>(...keys: Keys) => {
18
+ readonly [K in Keys[number]]: (value: Model[K] | Snapshot<Model[K]>) => void;
19
+ };
20
+ readonly patch: (changes: Partial<Model> | Partial<Snapshot<Model>>) => void;
21
+ readonly edit: <K extends keyof Model>(key: K, change: (value: Snapshot<Model[K]>) => Model[K] | Snapshot<Model[K]>) => void;
18
22
  /** Synchronous transaction. Reads see staged changes; a throw discards changes and work. */
19
23
  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 run: (slot: CommandSlot, effect: Effect.Effect<unknown, unknown, R>, policy: TaskPolicy) => void;
25
+ readonly cancel: (slot: CommandSlot) => void;
26
+ readonly isRunning: (slot: CommandSlot) => boolean;
27
+ readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
28
+ /** Interrupt and join work before closing owned dependencies in reverse order. */
29
+ readonly close: () => Promise<void>;
24
30
  readonly dispose: () => void;
25
31
  }
26
32
  type Options = {
27
- checkSnapshots?: boolean;
28
33
  name?: string;
29
34
  onDefect?: ReportError;
30
35
  };
@@ -32,4 +37,3 @@ export declare function modelOwner<Model extends object>(initial: Model, options
32
37
  export declare function modelOwner<Model extends object, R>(initial: Model, options: Options & {
33
38
  runtime: UiRuntime<R>;
34
39
  }): ModelOwner<Model, R>;
35
- export {};
package/dist/owner.js CHANGED
@@ -1,62 +1,45 @@
1
- import { protectSnapshot, checkSnapshotsByDefault } from './snapshot.js';
1
+ import { protectSnapshot } from './snapshot.js';
2
2
  import { Effect } from 'effect';
3
- import { program } from './program.js';
3
+ import { program, } from './program.js';
4
4
  import { patchModel } from './state.js';
5
5
  import { runAll, reportError } from './errors.js';
6
6
  export function modelOwner(initial, options = {}) {
7
- let disposed = false, nextTask = 0;
7
+ let disposed = false;
8
8
  let staged;
9
- const groups = new Map();
10
9
  const cleanups = [];
10
+ let closing;
11
11
  const source = program({
12
12
  initial,
13
13
  ...options,
14
- update(model, operations) {
14
+ update(snapshot, operations) {
15
+ let model = snapshot;
15
16
  const single = operations.length === 1 ? operations[0] : undefined;
16
17
  if (single?.type === 'Patch')
17
18
  return { model: patchModel(model, single.changes) };
18
- const commands = new Map();
19
+ const commands = [];
19
20
  const cancel = new Set();
20
- const stop = (slot) => {
21
- for (const key of groups.get(slot) ?? []) {
22
- cancel.add(key);
23
- commands.delete(key);
24
- }
25
- groups.delete(slot);
26
- };
27
21
  for (const operation of operations) {
28
22
  if (operation.type === 'Patch')
29
23
  model = patchModel(model, operation.changes);
30
- else if (operation.type === 'Cancel')
31
- stop(operation.slot);
24
+ else if (operation.type === 'Cancel') {
25
+ cancel.add(operation.slot);
26
+ for (let index = commands.length - 1; index >= 0; index--)
27
+ if (commands[index].slot === operation.slot)
28
+ commands.splice(index, 1);
29
+ }
32
30
  else {
33
31
  const { slot, effect, policy } = operation;
34
- if (policy === 'drop' && groups.has(slot))
35
- continue;
36
- if (policy === 'replace')
37
- stop(slot);
38
- const key = `${slot}:${++nextTask}`;
39
- const group = groups.get(slot) ?? new Set();
40
- groups.set(slot, group);
41
- group.add(key);
42
- commands.set(key, {
43
- slot: key,
44
- action: Effect.asVoid(effect).pipe(Effect.ensuring(Effect.sync(() => {
45
- group.delete(key);
46
- if (!group.size && groups.get(slot) === group)
47
- groups.delete(slot);
48
- }))),
49
- });
32
+ commands.push({ slot, policy, action: Effect.asVoid(effect) });
50
33
  }
51
34
  }
52
35
  return {
53
36
  model,
54
37
  cancel: [...cancel],
55
- commands: [...commands.values()].map((command) => options.runtime ? options.runtime.command(command) : command),
38
+ commands: commands.map((command) => options.runtime ? options.runtime.command(command) : command),
56
39
  };
57
40
  },
58
41
  });
59
- const read = () => protectSnapshot(staged?.model ?? source.model(), options.checkSnapshots ?? checkSnapshotsByDefault);
42
+ const read = () => protectSnapshot(staged?.model ?? source.model());
60
43
  const submit = (operation) => {
61
44
  if (disposed)
62
45
  return;
@@ -74,13 +57,56 @@ export function modelOwner(initial, options = {}) {
74
57
  return;
75
58
  disposed = true;
76
59
  source.dispose();
77
- groups.clear();
78
- runAll(cleanups.splice(0).reverse(), options.onDefect ?? reportError);
60
+ runAll(cleanups
61
+ .splice(0)
62
+ .reverse()
63
+ .map((resource) => () => resource.dispose()), options.onDefect ?? reportError);
64
+ };
65
+ const close = () => {
66
+ if (closing)
67
+ return closing;
68
+ disposed = true;
69
+ const resources = cleanups.splice(0).reverse();
70
+ let finish;
71
+ let fail;
72
+ closing = new Promise((resolve, reject) => {
73
+ finish = resolve;
74
+ fail = reject;
75
+ });
76
+ const closeResources = async () => {
77
+ await source.close();
78
+ const errors = [];
79
+ for (const resource of resources) {
80
+ try {
81
+ if (resource.close)
82
+ await resource.close();
83
+ else
84
+ resource.dispose();
85
+ }
86
+ catch (error) {
87
+ errors.push(error);
88
+ }
89
+ }
90
+ if (errors.length)
91
+ throw new AggregateError(errors, 'Owner cleanup failed.');
92
+ };
93
+ closeResources().then(finish, fail);
94
+ return closing;
79
95
  };
80
96
  return {
81
- source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose },
97
+ source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose, close },
82
98
  read,
83
99
  patch,
100
+ fields: (...keys) => {
101
+ const controls = Object.create(null);
102
+ for (const key of keys) {
103
+ Object.defineProperty(controls, key, {
104
+ enumerable: true,
105
+ value: (value) => patch({ [key]: value }),
106
+ });
107
+ }
108
+ return Object.freeze(controls);
109
+ },
84
110
  edit: (key, change) => {
85
111
  if (!disposed) {
86
112
  const changes = {};
@@ -116,18 +142,19 @@ export function modelOwner(initial, options = {}) {
116
142
  staged = parent;
117
143
  }
118
144
  },
119
- run: (slot, effect, policy = 'replace') => submit({ type: 'Run', slot, effect, policy }),
145
+ run: (slot, effect, policy) => submit({ type: 'Run', slot, effect, policy }),
120
146
  cancel: (slot) => submit({ type: 'Cancel', slot }),
121
- isRunning: (slot) => groups.has(slot),
147
+ isRunning: (slot) => source.activeSlots().includes(slot),
122
148
  awaitIdle: source.awaitIdle,
123
149
  get disposed() {
124
150
  return disposed;
125
151
  },
152
+ close,
126
153
  own(resource) {
127
154
  if (disposed)
128
155
  resource.dispose();
129
156
  else
130
- cleanups.push(() => resource.dispose());
157
+ cleanups.push(resource);
131
158
  return resource;
132
159
  },
133
160
  dispose,
package/dist/pages.d.ts CHANGED
@@ -1,49 +1,51 @@
1
+ import type { Snapshot } from './snapshot.js';
1
2
  import { Cause } from 'effect';
2
3
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
4
  import type { UiLoad, UiPage } from './load.js';
4
- import { type Transition, type Send } from './program.js';
5
- export interface PagesModel<Props, A, Cursor> {
5
+ import { type Transition, type RunningProgram } from './program.js';
6
+ import { type UiRuntime } from './runtime.js';
7
+ export interface PagesModel<Props, A, Cursor, E = unknown> {
6
8
  readonly props: Props;
7
9
  readonly key: string | undefined;
8
- readonly result: AsyncResult.AsyncResult<UiPage<A, Cursor>, unknown>;
10
+ readonly result: AsyncResult.AsyncResult<UiPage<A, Cursor>, E>;
9
11
  readonly append: boolean;
10
12
  }
11
- export type PagesMessage<A, Cursor> = {
13
+ export type PagesRequest = {
12
14
  type: 'More';
13
15
  } | {
14
16
  type: 'Retry';
15
17
  } | {
16
18
  type: 'Refresh';
17
- } | {
19
+ };
20
+ /** Low-level reducer protocol, including completions for explicit program composition. */
21
+ export type PagesMessage<A, Cursor, E = unknown> = PagesRequest | {
18
22
  type: 'Loaded';
19
23
  page: UiPage<A, Cursor>;
20
24
  append: boolean;
21
25
  } | {
22
26
  type: 'Failed';
23
- cause: Cause.Cause<unknown>;
27
+ cause: Cause.Cause<E>;
24
28
  };
29
+ /** An owned pagination source with immutable input updates. */
30
+ export interface PagesProgram<Props, A, Cursor, E = unknown> extends RunningProgram<PagesModel<Props, A, Cursor, E>, PagesRequest> {
31
+ readonly receive: (props: Props | Snapshot<Props>) => void;
32
+ }
33
+ /** Reusable pagination transitions, or an independently owned source through create. */
34
+ export interface Pagination<Props, A, Cursor, E = unknown> {
35
+ readonly init: (props: Props | Snapshot<Props>) => PagesModel<Props, A, Cursor, E> | Snapshot<PagesModel<Props, A, Cursor, E>>;
36
+ readonly receive: (model: PagesModel<Props, A, Cursor, E> | Snapshot<PagesModel<Props, A, Cursor, E>>, props: Props | Snapshot<Props>, options?: {
37
+ refresh?: boolean;
38
+ }) => Transition<PagesModel<Props, A, Cursor, E>, PagesMessage<A, Cursor, E>>;
39
+ readonly update: (model: PagesModel<Props, A, Cursor, E> | Snapshot<PagesModel<Props, A, Cursor, E>>, message: PagesMessage<A, Cursor, E>) => Transition<PagesModel<Props, A, Cursor, E>, PagesMessage<A, Cursor, E>>;
40
+ readonly create: (props: Props | Snapshot<Props>) => PagesProgram<Props, A, Cursor, E>;
41
+ }
25
42
  /**
26
43
  * Pagination owns the `page` command slot. Key changes clear results and replace the request;
27
44
  * same-key refreshes keep the last success, and retries repeat the failed cursor.
28
45
  * Feed request inputs through receive; pass { refresh: true } for explicit same-key submissions.
29
46
  */
30
- export declare function pages<Props, A, Cursor>(definition: {
31
- key: (props: Props) => string | undefined;
32
- load: (props: Props, cursor: Cursor | undefined) => UiLoad<UiPage<A, Cursor>>;
33
- itemKey: (item: A) => string;
34
- }): {
35
- create(props: Props): {
36
- send: Send<PagesMessage<A, Cursor>>;
37
- receive: (props: Props) => void;
38
- activeSlots: () => readonly string[];
39
- awaitIdle: (slot?: string) => Promise<void>;
40
- model: () => Readonly<PagesModel<Props, A, Cursor>>;
41
- subscribe: (listener: (model: Readonly<PagesModel<Props, A, Cursor>>) => void) => () => void;
42
- dispose: () => void;
43
- };
44
- init: (props: Props) => PagesModel<Props, A, Cursor>;
45
- receive(model: PagesModel<Props, A, Cursor>, props: Props, options?: {
46
- refresh?: boolean;
47
- }): Transition<PagesModel<Props, A, Cursor>, PagesMessage<A, Cursor>>;
48
- update(model: PagesModel<Props, A, Cursor>, message: PagesMessage<A, Cursor>): Transition<PagesModel<Props, A, Cursor>, PagesMessage<A, Cursor>>;
49
- };
47
+ export declare function pages<Props, A, Cursor, E = unknown, R = never>(definition: {
48
+ key: (props: Snapshot<Props>) => string | undefined;
49
+ load: (props: Snapshot<Props>, cursor: Snapshot<Cursor> | undefined) => UiLoad<UiPage<A, Cursor>, E, R>;
50
+ itemKey: (item: Snapshot<A>) => string;
51
+ }, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): Pagination<Props, A, Cursor, E>;