effectweb 0.3.0 → 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.
@@ -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.js CHANGED
@@ -25,7 +25,15 @@ export function lazyView(load, ...provided) {
25
25
  scope.cleanups.push(interrupt);
26
26
  if (scope.disposed)
27
27
  return;
28
- fiber = Effect.runFork(runtime.provide(Effect.suspend(load)));
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
+ }
29
37
  if (scope.disposed) {
30
38
  interrupt();
31
39
  return;
package/dist/mount.d.ts CHANGED
@@ -17,6 +17,8 @@ export declare function domMount<T extends Element, R = never>(start: (element:
17
17
  /** A stable DOM lifecycle with fresh immutable inputs, for focus, measurements and native events. */
18
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
19
  export declare function startMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError): {
20
+ closed: Promise<void>;
21
+ close(): Promise<void>;
20
22
  update(next: DomMount<T>): boolean;
21
- dispose(): void;
23
+ dispose: () => void;
22
24
  };
package/dist/mount.js CHANGED
@@ -31,11 +31,39 @@ export function startMount(element, mount, report = reportError) {
31
31
  const work = mount.acquire(element, () => data);
32
32
  const fiber = Effect.isEffect(work) ? Effect.runFork(work) : undefined;
33
33
  let disposed = false;
34
+ let finish;
35
+ const closed = new Promise((resolve) => {
36
+ finish = resolve;
37
+ });
34
38
  fiber?.addObserver((exit) => {
35
39
  if (exit._tag === 'Failure' && !disposed)
36
40
  reportSafely(report, exit.cause);
41
+ finish();
37
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
+ };
38
61
  return {
62
+ closed,
63
+ close() {
64
+ dispose();
65
+ return closed;
66
+ },
39
67
  update(next) {
40
68
  if (next.identity !== mount.identity)
41
69
  return false;
@@ -44,16 +72,6 @@ export function startMount(element, mount, report = reportError) {
44
72
  work.update?.();
45
73
  return true;
46
74
  },
47
- dispose() {
48
- if (disposed)
49
- return;
50
- disposed = true;
51
- if (fiber)
52
- Effect.runFork(Fiber.interrupt(fiber));
53
- else if (typeof work === 'function')
54
- work();
55
- else if ('dispose' in work)
56
- work.dispose();
57
- },
75
+ dispose,
58
76
  };
59
77
  }
package/dist/owner.d.ts CHANGED
@@ -24,7 +24,9 @@ export interface ModelOwner<Model extends object, R = never> extends DisposableO
24
24
  readonly run: (slot: CommandSlot, effect: Effect.Effect<unknown, unknown, R>, policy: TaskPolicy) => void;
25
25
  readonly cancel: (slot: CommandSlot) => void;
26
26
  readonly isRunning: (slot: CommandSlot) => boolean;
27
- readonly awaitIdle: () => Promise<void>;
27
+ readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
28
+ /** Interrupt and join work before closing owned dependencies in reverse order. */
29
+ readonly close: () => Promise<void>;
28
30
  readonly dispose: () => void;
29
31
  }
30
32
  type Options = {
package/dist/owner.js CHANGED
@@ -7,6 +7,7 @@ export function modelOwner(initial, options = {}) {
7
7
  let disposed = false;
8
8
  let staged;
9
9
  const cleanups = [];
10
+ let closing;
10
11
  const source = program({
11
12
  initial,
12
13
  ...options,
@@ -56,10 +57,44 @@ export function modelOwner(initial, options = {}) {
56
57
  return;
57
58
  disposed = true;
58
59
  source.dispose();
59
- 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;
60
95
  };
61
96
  return {
62
- source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose },
97
+ source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose, close },
63
98
  read,
64
99
  patch,
65
100
  fields: (...keys) => {
@@ -114,11 +149,12 @@ export function modelOwner(initial, options = {}) {
114
149
  get disposed() {
115
150
  return disposed;
116
151
  },
152
+ close,
117
153
  own(resource) {
118
154
  if (disposed)
119
155
  resource.dispose();
120
156
  else
121
- cleanups.push(() => resource.dispose());
157
+ cleanups.push(resource);
122
158
  return resource;
123
159
  },
124
160
  dispose,
package/dist/pages.d.ts CHANGED
@@ -3,10 +3,11 @@ import { Cause } from 'effect';
3
3
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
4
4
  import type { UiLoad, UiPage } from './load.js';
5
5
  import { type Transition, type RunningProgram } from './program.js';
6
- export interface PagesModel<Props, A, Cursor> {
6
+ import { type UiRuntime } from './runtime.js';
7
+ export interface PagesModel<Props, A, Cursor, E = unknown> {
7
8
  readonly props: Props;
8
9
  readonly key: string | undefined;
9
- readonly result: AsyncResult.AsyncResult<UiPage<A, Cursor>, unknown>;
10
+ readonly result: AsyncResult.AsyncResult<UiPage<A, Cursor>, E>;
10
11
  readonly append: boolean;
11
12
  }
12
13
  export type PagesRequest = {
@@ -17,34 +18,34 @@ export type PagesRequest = {
17
18
  type: 'Refresh';
18
19
  };
19
20
  /** Low-level reducer protocol, including completions for explicit program composition. */
20
- export type PagesMessage<A, Cursor> = PagesRequest | {
21
+ export type PagesMessage<A, Cursor, E = unknown> = PagesRequest | {
21
22
  type: 'Loaded';
22
23
  page: UiPage<A, Cursor>;
23
24
  append: boolean;
24
25
  } | {
25
26
  type: 'Failed';
26
- cause: Cause.Cause<unknown>;
27
+ cause: Cause.Cause<E>;
27
28
  };
28
29
  /** An owned pagination source with immutable input updates. */
29
- export interface PagesProgram<Props, A, Cursor> extends RunningProgram<PagesModel<Props, A, Cursor>, PagesRequest> {
30
+ export interface PagesProgram<Props, A, Cursor, E = unknown> extends RunningProgram<PagesModel<Props, A, Cursor, E>, PagesRequest> {
30
31
  readonly receive: (props: Props | Snapshot<Props>) => void;
31
32
  }
32
33
  /** Reusable pagination transitions, or an independently owned source through create. */
33
- export interface Pagination<Props, A, Cursor> {
34
- readonly init: (props: Props | Snapshot<Props>) => PagesModel<Props, A, Cursor> | Snapshot<PagesModel<Props, A, Cursor>>;
35
- readonly receive: (model: PagesModel<Props, A, Cursor> | Snapshot<PagesModel<Props, A, Cursor>>, props: Props | Snapshot<Props>, options?: {
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?: {
36
37
  refresh?: boolean;
37
- }) => Transition<PagesModel<Props, A, Cursor>, PagesMessage<A, Cursor>>;
38
- readonly update: (model: PagesModel<Props, A, Cursor> | Snapshot<PagesModel<Props, A, Cursor>>, message: PagesMessage<A, Cursor>) => Transition<PagesModel<Props, A, Cursor>, PagesMessage<A, Cursor>>;
39
- readonly create: (props: Props | Snapshot<Props>) => PagesProgram<Props, A, Cursor>;
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>;
40
41
  }
41
42
  /**
42
43
  * Pagination owns the `page` command slot. Key changes clear results and replace the request;
43
44
  * same-key refreshes keep the last success, and retries repeat the failed cursor.
44
45
  * Feed request inputs through receive; pass { refresh: true } for explicit same-key submissions.
45
46
  */
46
- export declare function pages<Props, A, Cursor>(definition: {
47
+ export declare function pages<Props, A, Cursor, E = unknown, R = never>(definition: {
47
48
  key: (props: Snapshot<Props>) => string | undefined;
48
- load: (props: Snapshot<Props>, cursor: Snapshot<Cursor> | undefined) => UiLoad<UiPage<A, Cursor>>;
49
+ load: (props: Snapshot<Props>, cursor: Snapshot<Cursor> | undefined) => UiLoad<UiPage<A, Cursor>, E, R>;
49
50
  itemKey: (item: Snapshot<A>) => string;
50
- }): Pagination<Props, A, Cursor>;
51
+ }, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): Pagination<Props, A, Cursor, E>;
package/dist/pages.js CHANGED
@@ -3,14 +3,16 @@ import { shareData } from './sharing.js';
3
3
  import { Cause, Option } from 'effect';
4
4
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
5
5
  import { effectCommand, program, } from './program.js';
6
+ import { defaultUiRuntime } from './runtime.js';
6
7
  import { available } from './resource.js';
7
- const commandPage = commandSlot('page');
8
8
  /**
9
9
  * Pagination owns the `page` command slot. Key changes clear results and replace the request;
10
10
  * same-key refreshes keep the last success, and retries repeat the failed cursor.
11
11
  * Feed request inputs through receive; pass { refresh: true } for explicit same-key submissions.
12
12
  */
13
- export function pages(definition) {
13
+ export function pages(definition, ...provided) {
14
+ const runtime = provided[0] ?? defaultUiRuntime;
15
+ const commandPage = commandSlot('page');
14
16
  const request = (model, append) => {
15
17
  const previous = available(model.result);
16
18
  if (append && (model.result.waiting || !previous || previous.next === undefined))
@@ -21,11 +23,11 @@ export function pages(definition) {
21
23
  return {
22
24
  model: { ...model, append, result: AsyncResult.waiting(model.result) },
23
25
  commands: [
24
- effectCommand(commandPage, () => definition.load(model.props, cursor), {
26
+ runtime.command(effectCommand(commandPage, () => definition.load(model.props, cursor), {
25
27
  policy: 'replace',
26
28
  onSuccess: (page) => ({ type: 'Loaded', page, append }),
27
29
  onFailure: (cause) => ({ type: 'Failed', cause }),
28
- }),
30
+ })),
29
31
  ],
30
32
  };
31
33
  };
package/dist/program.d.ts CHANGED
@@ -13,6 +13,8 @@ export declare const commandSlot: (name: string) => CommandSlot;
13
13
  export type Command<Message, R = never> = {
14
14
  readonly slot: CommandSlot;
15
15
  readonly policy: TaskPolicy;
16
+ /** Notification for work that never started. */
17
+ readonly onDiscard?: (reason: 'Cancelled' | 'Superseded' | 'Dropped') => void;
16
18
  } & ({
17
19
  readonly effect: Effect.Effect<Message, never, R>;
18
20
  readonly stream?: never;
@@ -40,13 +42,21 @@ export interface Transition<Model, Message, R = never> {
40
42
  readonly commands?: readonly Command<Message, R>[];
41
43
  readonly cancel?: readonly CommandSlot[];
42
44
  }
45
+ export declare function mapTransition<Model, Message, Parent, ParentMessage, R = never>(transition: Transition<Model, Message, R>, maps: {
46
+ readonly model: (model: Snapshot<Model>) => Parent | Snapshot<Parent>;
47
+ readonly message: (message: Message) => ParentMessage;
48
+ }): Transition<Parent, ParentMessage, R>;
43
49
  export interface Program<Model, Message> {
44
50
  readonly model: () => Snapshot<Model>;
45
51
  readonly send: Send<Message>;
46
52
  readonly subscribe: (listener: (model: Snapshot<Model>) => void) => () => void;
47
53
  readonly dispose: () => void;
54
+ readonly close?: () => Promise<void>;
48
55
  }
49
56
  export interface RunningProgram<Model, Message> extends Program<Model, Message> {
57
+ /** Wait for interrupted work and its finalizers as well as admitted work. */
58
+ readonly awaitStopped: () => Promise<void>;
59
+ readonly close: () => Promise<void>;
50
60
  readonly activeSlots: () => readonly CommandSlot[];
51
61
  readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
52
62
  }
package/dist/program.js CHANGED
@@ -23,6 +23,15 @@ export function mapCommand(command, map) {
23
23
  ? { ...command, stream: command.stream.pipe(Stream.map(map)) }
24
24
  : { ...command, effect: command.effect.pipe(Effect.map(map)) };
25
25
  }
26
+ export function mapTransition(transition, maps) {
27
+ return {
28
+ model: maps.model(transition.model),
29
+ ...(transition.cancel ? { cancel: transition.cancel } : {}),
30
+ ...(transition.commands
31
+ ? { commands: transition.commands.map((command) => mapCommand(command, maps.message)) }
32
+ : {}),
33
+ };
34
+ }
26
35
  export function program(options) {
27
36
  const id = nextProgramId();
28
37
  const trace = (kind, slot, message) => hasProgramObservers() &&
@@ -40,6 +49,7 @@ export function program(options) {
40
49
  });
41
50
  const waiters = new Set();
42
51
  const notify = () => {
52
+ notifyStopped();
43
53
  for (const waiter of waiters) {
44
54
  if (disposed ||
45
55
  (!draining && (waiter.slot ? !running.has(waiter.slot) : running.size === 0))) {
@@ -53,11 +63,33 @@ export function program(options) {
53
63
  let current = protectSnapshot(options.initial);
54
64
  const listeners = new Set();
55
65
  const running = new Map();
66
+ const live = new Set();
67
+ const stopped = new Set();
68
+ const notifyStopped = () => {
69
+ if (live.size || running.size || draining)
70
+ return;
71
+ for (const done of stopped)
72
+ done();
73
+ stopped.clear();
74
+ };
75
+ const awaitStopped = () => live.size === 0 && running.size === 0 && !draining
76
+ ? Promise.resolve()
77
+ : new Promise((done) => stopped.add(done));
56
78
  let disposed = false;
57
79
  let draining = false;
58
80
  const queue = [];
59
81
  const deferred = [];
60
- const cancel = (slot) => {
82
+ const discard = (command, reason) => {
83
+ if (command.onDiscard) {
84
+ try {
85
+ command.onDiscard(reason);
86
+ }
87
+ catch (error) {
88
+ reportSafely(options.onDefect ?? reportError, error);
89
+ }
90
+ }
91
+ };
92
+ const cancel = (slot, reason = 'Cancelled') => {
61
93
  // A synchronous Effect can enqueue completion behind a reset/replacement message.
62
94
  // Its fiber is already done, but cancellation still owns that unpublished completion.
63
95
  for (let index = queue.length - 1; index >= 0; index--)
@@ -67,10 +99,15 @@ export function program(options) {
67
99
  running.delete(slot);
68
100
  if (previous) {
69
101
  trace('cancel', slot);
70
- previous.pending.length = 0;
71
- for (const task of previous.active)
102
+ const pending = previous.pending.splice(0);
103
+ for (const command of pending)
104
+ discard(command, reason);
105
+ for (const task of previous.active) {
72
106
  if (task.fiber)
73
107
  Effect.runFork(Fiber.interrupt(task.fiber));
108
+ else if (task.command && !live.has(task))
109
+ discard(task.command, reason);
110
+ }
74
111
  previous.active.clear();
75
112
  }
76
113
  };
@@ -104,13 +141,12 @@ export function program(options) {
104
141
  : command.action
105
142
  ? command.action.pipe(Effect.as(Option.none()))
106
143
  : command.effect.pipe(Effect.map(Option.some));
144
+ live.add(task);
107
145
  const fiber = Effect.runFork(effect);
108
146
  task.fiber = fiber;
109
- if (!valid()) {
110
- Effect.runFork(Fiber.interrupt(fiber));
111
- return;
112
- }
113
147
  fiber.addObserver((exit) => {
148
+ live.delete(task);
149
+ notifyStopped();
114
150
  if (!valid())
115
151
  return;
116
152
  group.active.delete(task);
@@ -133,6 +169,8 @@ export function program(options) {
133
169
  }
134
170
  notify();
135
171
  });
172
+ if (!valid())
173
+ Effect.runFork(Fiber.interrupt(fiber));
136
174
  };
137
175
  const advance = (slot, group) => {
138
176
  if (disposed || running.get(slot) !== group || group.active.size)
@@ -140,7 +178,7 @@ export function program(options) {
140
178
  const next = group.pending.shift();
141
179
  if (!next)
142
180
  return;
143
- const task = {};
181
+ const task = { command: next };
144
182
  group.active.add(task);
145
183
  launch(next, group, task);
146
184
  };
@@ -181,15 +219,18 @@ export function program(options) {
181
219
  break;
182
220
  const policy = command.policy;
183
221
  let group = running.get(command.slot);
184
- if (policy === 'drop' && group)
222
+ if (policy === 'drop' && group) {
223
+ discard(command, 'Dropped');
185
224
  continue;
225
+ }
186
226
  if (policy === 'replace') {
187
- cancel(command.slot);
227
+ cancel(command.slot, 'Superseded');
188
228
  group = undefined;
189
229
  }
190
230
  if (group && (policy === 'queue' || policy === 'latest-queued')) {
191
231
  if (policy === 'latest-queued')
192
- group.pending.length = 0;
232
+ for (const pending of group.pending.splice(0))
233
+ discard(pending, 'Superseded');
193
234
  group.pending.push(command);
194
235
  continue;
195
236
  }
@@ -197,7 +238,7 @@ export function program(options) {
197
238
  group = { active: new Set(), pending: [] };
198
239
  running.set(command.slot, group);
199
240
  }
200
- const task = {};
241
+ const task = { command };
201
242
  group.active.add(task);
202
243
  const admitted = group;
203
244
  starts.push(() => launch(command, admitted, task));
@@ -221,7 +262,24 @@ export function program(options) {
221
262
  }
222
263
  };
223
264
  const send = (message) => enqueue(message);
265
+ const dispose = () => {
266
+ if (disposed)
267
+ return;
268
+ disposed = true;
269
+ queue.length = 0;
270
+ deferred.length = 0;
271
+ for (const slot of running.keys())
272
+ cancel(slot);
273
+ listeners.clear();
274
+ trace('dispose');
275
+ notify();
276
+ };
224
277
  return {
278
+ awaitStopped,
279
+ close: () => {
280
+ dispose();
281
+ return awaitStopped();
282
+ },
225
283
  model: () => current,
226
284
  activeSlots: () => [...running.keys()],
227
285
  awaitIdle: (slot) => disposed || (!draining && (slot ? !running.has(slot) : running.size === 0))
@@ -246,17 +304,6 @@ export function program(options) {
246
304
  listeners.delete(receive);
247
305
  };
248
306
  },
249
- dispose() {
250
- if (disposed)
251
- return;
252
- disposed = true;
253
- queue.length = 0;
254
- deferred.length = 0;
255
- for (const slot of running.keys())
256
- cancel(slot);
257
- listeners.clear();
258
- trace('dispose');
259
- notify();
260
- },
307
+ dispose,
261
308
  };
262
309
  }
@@ -1,9 +1,11 @@
1
1
  import type { Effect } from 'effect';
2
2
  import type { Snapshot } from './snapshot.js';
3
- import type { Query } from './query.js';
3
+ import type { Query, QueryGroup } from './query.js';
4
4
  export interface QueryDefinition<Args, A, E, R> {
5
5
  readonly name: string;
6
- readonly load: (args: Snapshot<Args>) => Effect.Effect<A, E, R>;
6
+ readonly load: (args: Snapshot<Args>, previous?: Snapshot<A>) => Effect.Effect<A, E, R>;
7
+ readonly groups?: readonly QueryGroup[];
8
+ readonly unused?: 'retain' | 'cancel';
7
9
  readonly staleTime: number;
8
10
  readonly share?: (previous: Snapshot<A>, next: A | Snapshot<A>) => A | Snapshot<A>;
9
11
  }
package/dist/query.d.ts CHANGED
@@ -27,4 +27,10 @@ export declare function query<Args, A, E = never, R = never>(definition: Definit
27
27
  export declare function query<A, E = never, R = never>(definition: Definition<true, A, E, R> & {
28
28
  readonly load: () => Effect.Effect<A, E, R>;
29
29
  }): Query<true, A, E, R>;
30
+ declare const groupType: unique symbol;
31
+ export type QueryGroup = symbol & {
32
+ readonly [groupType]: true;
33
+ };
34
+ /** Invalidation identity; equal diagnostic names do not share membership. */
35
+ export declare const queryGroup: (name: string) => QueryGroup;
30
36
  export {};
package/dist/query.js CHANGED
@@ -60,6 +60,12 @@ export function query(definition) {
60
60
  if (Number.isNaN(staleTime) || staleTime < 0)
61
61
  throw new RangeError('Query staleTime must be nonnegative.');
62
62
  const result = Object.freeze({ name: definition.name });
63
- registerQuery(result, Object.freeze({ ...definition, staleTime }));
63
+ registerQuery(result, Object.freeze({
64
+ ...definition,
65
+ ...(definition.groups ? { groups: Object.freeze([...definition.groups]) } : {}),
66
+ staleTime,
67
+ }));
64
68
  return result;
65
69
  }
70
+ /** Invalidation identity; equal diagnostic names do not share membership. */
71
+ export const queryGroup = (name) => Symbol(name);
package/dist/session.js CHANGED
@@ -38,7 +38,10 @@ export function queryResource(context, definition) {
38
38
  let revision = 0;
39
39
  const initial = AsyncResult.initial();
40
40
  const listeners = new Set();
41
- const read = () => atom && !disposed && generation === internal.registry.get(internal.generation)
41
+ const read = () => atom &&
42
+ !disposed &&
43
+ !internal.disposed() &&
44
+ generation === internal.registry.get(internal.generation)
42
45
  ? internal.registry.get(atom)
43
46
  : initial;
44
47
  let published;
@@ -102,7 +105,7 @@ export function queryResource(context, definition) {
102
105
  });
103
106
  return {
104
107
  select(args) {
105
- if (disposed)
108
+ if (disposed || internal.disposed())
106
109
  return;
107
110
  const nextGeneration = internal.registry.get(internal.generation);
108
111
  const nextKey = args === undefined ? undefined : encodeQueryKey(args);
@@ -118,7 +121,12 @@ export function queryResource(context, definition) {
118
121
  atom = nextAtom;
119
122
  if (nextAtom) {
120
123
  // Subscription setup itself can execute a synchronous Effect and reenter selection.
121
- const release = internal.registry.subscribe(nextAtom, notify);
124
+ const releaseUse = internal.retain(nextAtom);
125
+ const unsubscribe = internal.registry.subscribe(nextAtom, notify);
126
+ const release = () => {
127
+ unsubscribe();
128
+ releaseUse();
129
+ };
122
130
  if (disposed || selected !== revision) {
123
131
  release();
124
132
  return;
@@ -139,9 +147,10 @@ export function queryResource(context, definition) {
139
147
  refresh: () => {
140
148
  if (atom &&
141
149
  !disposed &&
150
+ !internal.disposed() &&
142
151
  generation === internal.registry.get(internal.generation) &&
143
152
  !internal.registry.get(atom).waiting)
144
- internal.registry.refresh(atom);
153
+ internal.refresh(atom);
145
154
  },
146
155
  dispose: () => {
147
156
  if (disposed)
@@ -0,0 +1,7 @@
1
+ /** Internal completion accounting, including reentrant acquisition before a fiber is available. */
2
+ export declare class Settlement {
3
+ private pending;
4
+ private waiters;
5
+ begin(): () => void;
6
+ wait(): Promise<void>;
7
+ }