effectweb 0.1.1 → 0.2.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.
@@ -1,4 +1,4 @@
1
- import * as _tv_dom from "./dom.js";
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: _tv_dom.compiled((_tv_scope_1, _tv_parent_2, _tv_before_3) => {
48
- const _tv_derived_4 = _tv_scope_1.derive(() => [_tv_scope_1.value?.props], () => ((_tv_capture_5) => _tv_capture_5.props)(_tv_scope_1.value));
49
- const _tv_derived_6 = _tv_scope_1.derive(() => [_tv_derived_4()?.result], () => ((_tv_capture_7) => AsyncResult.value(_tv_capture_7.result))(_tv_derived_4()));
50
- const _tv_derived_8 = _tv_scope_1.derive(() => [_tv_derived_4()?.result, _tv_derived_4()?.result?.waiting], () => ((_tv_capture_9) => AsyncResult.isFailure(_tv_capture_9.result) && !_tv_capture_9.result.waiting ? _tv_capture_9.result : undefined)(_tv_derived_4()));
51
- _tv_dom.branch(_tv_scope_1, _tv_parent_2, _tv_before_3, () => Option.isSome(_tv_derived_6()), (_tv_scope_1, _tv_parent_10, _tv_before_11) => {
52
- _tv_dom.text(_tv_scope_1, _tv_parent_10, _tv_before_11, () => [_tv_derived_4(), _tv_derived_6()?.value], () => _tv_derived_4().content(_tv_derived_6().value));
53
- _tv_dom.branch(_tv_scope_1, _tv_parent_10, _tv_before_11, () => _tv_derived_4().result.waiting, (_tv_scope_1, _tv_parent_12, _tv_before_13) => {
54
- _tv_dom.text(_tv_scope_1, _tv_parent_12, _tv_before_13, () => [_tv_derived_4()?.refreshing], () => _tv_derived_4().refreshing);
55
- }, (_tv_scope_1, _tv_parent_12, _tv_before_13) => { });
56
- _tv_dom.branch(_tv_scope_1, _tv_parent_10, _tv_before_11, () => _tv_derived_8() && _tv_derived_4().failure, (_tv_scope_1, _tv_parent_14, _tv_before_15) => {
57
- _tv_dom.text(_tv_scope_1, _tv_parent_14, _tv_before_15, () => [_tv_derived_4(), _tv_derived_8()?.cause], () => _tv_derived_4().failure(_tv_derived_8().cause));
58
- }, (_tv_scope_1, _tv_parent_14, _tv_before_15) => { });
59
- }, (_tv_scope_1, _tv_parent_10, _tv_before_11) => {
60
- _tv_dom.branch(_tv_scope_1, _tv_parent_10, _tv_before_11, () => _tv_derived_8() && _tv_derived_4().failure, (_tv_scope_1, _tv_parent_16, _tv_before_17) => {
61
- _tv_dom.text(_tv_scope_1, _tv_parent_16, _tv_before_17, () => [_tv_derived_4(), _tv_derived_8()?.cause], () => _tv_derived_4().failure(_tv_derived_8().cause));
62
- }, (_tv_scope_1, _tv_parent_16, _tv_before_17) => {
63
- _tv_dom.branch(_tv_scope_1, _tv_parent_16, _tv_before_17, () => _tv_scope_1.value.pending, (_tv_scope_1, _tv_parent_18, _tv_before_19) => {
64
- _tv_dom.branch(_tv_scope_1, _tv_parent_18, _tv_before_19, () => _tv_scope_1.value.visible, (_tv_scope_1, _tv_parent_20, _tv_before_21) => {
65
- _tv_dom.text(_tv_scope_1, _tv_parent_20, _tv_before_21, () => [_tv_derived_4()?.pending], () => _tv_derived_4().pending);
66
- }, (_tv_scope_1, _tv_parent_20, _tv_before_21) => { });
67
- }, (_tv_scope_1, _tv_parent_18, _tv_before_19) => {
68
- _tv_dom.text(_tv_scope_1, _tv_parent_18, _tv_before_19, () => [_tv_derived_4()?.empty], () => _tv_derived_4().empty);
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, type UiPage } from './load.js';
7
+ export { loadEffect, type UiLoad } from './load.js';
9
8
  export { shareValue } from './share.js';
10
- export declare function makeUiModel(): UiModel<never>;
11
- export declare function makeUiModel<R>(runtime: UiRuntime<R>): UiModel<R>;
12
- declare function createUiModel<R>(runtime?: UiRuntime<R>): {
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 UiModel<R = never> = ReturnType<typeof createUiModel<R>>;
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,10 +6,10 @@ 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 makeUiModel(runtime) {
10
- return createUiModel(runtime);
9
+ export function makeQueryCache(runtime) {
10
+ return createQueryCache(runtime);
11
11
  }
12
- function createUiModel(runtime) {
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();
@@ -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
- }
@@ -11,5 +11,9 @@ export interface Rows<A> {
11
11
  export declare function collection<A>(identity: (item: A, index: number) => Identity): {
12
12
  from: (items: readonly A[]) => Rows<A>;
13
13
  };
14
- /** Ordered protocol structures such as keyboard rows and rich-text fragments have positional identity. */
14
+ /** Use positional identity for ordered values without stable entity IDs. */
15
15
  export declare function sequence<A>(items: readonly A[]): Rows<A>;
16
+ /** Rows keyed by their domain IDs. Reuses the wrapper for the same immutable array. */
17
+ export declare function entities<A extends {
18
+ readonly id: Identity;
19
+ }>(items: readonly A[] | undefined): Rows<A>;
@@ -18,7 +18,14 @@ export function collection(identity) {
18
18
  return { from };
19
19
  }
20
20
  const positions = collection((_item, index) => index);
21
- /** Ordered protocol structures such as keyboard rows and rich-text fragments have positional identity. */
21
+ /** Use positional identity for ordered values without stable entity IDs. */
22
22
  export function sequence(items) {
23
23
  return positions.from(items);
24
24
  }
25
+ const empty = [];
26
+ const identified = collection((item) => item.id);
27
+ /** Rows keyed by their domain IDs. Reuses the wrapper for the same immutable array. */
28
+ export function entities(items) {
29
+ // The collection retains, filters and slices supplied items; it never inserts wider values.
30
+ return identified.from(items ?? empty);
31
+ }
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('MVU slot reached runtime without the snapshot JSX compiler');
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('MVU view reached runtime without the snapshot JSX compiler');
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
- const positions = new Map(previousIdentities.map((key, index) => [key, index]));
489
- const stationary = stationaryIndices(identities.map((key) => positions.get(key) ?? -1));
490
- let anchor = end;
491
- for (let index = identities.length - 1; index >= 0; index--) {
492
- const row = rows.get(identities[index]);
493
- if (!stationary.has(index) && row.end.nextSibling !== anchor) {
494
- const focused = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
495
- let node = row.start;
496
- while (node) {
497
- const next = node.nextSibling;
498
- const move = Reflect.get(target, 'moveBefore');
499
- if (typeof move === 'function' && target.isConnected && node.isConnected)
500
- move.call(target, node, anchor);
501
- else
502
- target.insertBefore(node, anchor);
503
- if (node === row.end)
504
- break;
505
- node = next;
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
- if (focused?.isConnected && document.activeElement !== focused)
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 { collection, sequence, type Rows } from './collection.js';
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 { makeUiModel, makeUiModel as makeQueryCache, makePagedResource, type UiModel, } from './cache.js';
22
- export { queryResource, resource, pagedResource, lifetime, projectionCache, sessionGroup, type SessionContext, type Read, } from './session.js';
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 { collection, sequence } from './collection.js';
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 { makeUiModel, makeUiModel as makeQueryCache, makePagedResource, } from './cache.js';
21
- export { queryResource, resource, pagedResource, lifetime, projectionCache, sessionGroup, } from './session.js';
21
+ export { makeQueryCache } from './cache.js';
22
+ export { queryResource, observeQuery, lifetime, projectionCache, sessionGroup, } from './session.js';
@@ -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
- return {
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 next = { ...model, props };
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
- update: (model: Model, message: Message) => Transition<Model, Message>;
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
@@ -8,7 +8,7 @@ export interface Query<Args, A, E = never, R = never> {
8
8
  /** Revalidate on activation/prefetch after this many milliseconds. No polling timer. */
9
9
  readonly staleTime: number;
10
10
  }
11
- /** Identity belongs to arguments, never callback identity. Each application cache owns account isolation. */
11
+ /** Identity belongs to arguments, never callback identity. Separate caches isolate independent data scopes. */
12
12
  export declare function query<A, E = never, R = never>(definition: {
13
13
  readonly name: string;
14
14
  readonly load: () => Effect.Effect<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) => AsyncResult.isFailure(result) ? String(Cause.squash(result.cause)) : '';
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, message: Msg) => Transition<M, Msg, R>;
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 { UiLoad, UiModel } from './cache.js';
4
- import { type UiPage } from './cache.js';
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: UiModel<R>;
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 explicitly revalidates.
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: SessionContext<R>, definition: Query<Args, A, E, NoInfer<R>>): {
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
- export declare function pagedResource<A, Cursor>(context: SessionContext, itemKey: (item: A) => string): {
34
- select(selected: string | undefined, load: (cursor: Cursor | undefined) => UiLoad<UiPage<A, Cursor>>): void;
35
- snapshot(): {
36
- items: A[];
37
- totalCount: number | undefined;
38
- loading: boolean;
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
- import { loadEffect, makePagedResource } from './cache.js';
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 explicitly revalidates.
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
- stop?.();
86
- stop = undefined;
109
+ const selected = ++revision;
110
+ disconnect();
87
111
  key = nextKey;
88
112
  generation = nextGeneration;
89
- atom = args === undefined ? undefined : context.cache.query(definition, args);
90
- if (atom)
91
- stop = context.cache.registry.subscribe(atom, context.changed, { immediate: true });
92
- context.changed();
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 && !disposed)
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
- stop?.();
104
- stop = undefined;
148
+ revision++;
149
+ listeners.clear();
150
+ stopGeneration();
151
+ disconnect();
105
152
  atom = undefined;
106
153
  key = undefined;
107
154
  },
108
155
  };
109
156
  }
110
- export function pagedResource(context, itemKey) {
111
- let key, page, stop;
112
- const initial = AsyncResult.initial();
113
- const state = () => (page ? context.cache.registry.get(page.atom) : initial);
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() {
@@ -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;
@@ -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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effectweb",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Immutable Effect models and compiled JSX with direct DOM rendering",
5
5
  "homepage": "https://github.com/DerpyCrabs/EffectWeb",
6
6
  "bugs": {