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
package/dist/pages.js CHANGED
@@ -1,14 +1,18 @@
1
- import { shareValue } from './share.js';
1
+ import { commandSlot } from './program.js';
2
+ import { shareData } from './sharing.js';
2
3
  import { Cause, Option } from 'effect';
3
4
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
4
- import { effectCommand, program } from './program.js';
5
+ import { effectCommand, program, } from './program.js';
6
+ import { defaultUiRuntime } from './runtime.js';
5
7
  import { available } from './resource.js';
6
8
  /**
7
9
  * Pagination owns the `page` command slot. Key changes clear results and replace the request;
8
10
  * same-key refreshes keep the last success, and retries repeat the failed cursor.
9
11
  * Feed request inputs through receive; pass { refresh: true } for explicit same-key submissions.
10
12
  */
11
- export function pages(definition) {
13
+ export function pages(definition, ...provided) {
14
+ const runtime = provided[0] ?? defaultUiRuntime;
15
+ const commandPage = commandSlot('page');
12
16
  const request = (model, append) => {
13
17
  const previous = available(model.result);
14
18
  if (append && (model.result.waiting || !previous || previous.next === undefined))
@@ -19,24 +23,27 @@ export function pages(definition) {
19
23
  return {
20
24
  model: { ...model, append, result: AsyncResult.waiting(model.result) },
21
25
  commands: [
22
- effectCommand('page', () => definition.load(model.props, cursor), {
26
+ runtime.command(effectCommand(commandPage, () => definition.load(model.props, cursor), {
27
+ policy: 'replace',
23
28
  onSuccess: (page) => ({ type: 'Loaded', page, append }),
24
29
  onFailure: (cause) => ({ type: 'Failed', cause }),
25
- }),
30
+ })),
26
31
  ],
27
32
  };
28
33
  };
29
34
  const pagination = {
30
35
  init: (props) => ({
31
- props,
36
+ props: props,
32
37
  key: undefined,
33
38
  result: AsyncResult.initial(),
34
39
  append: false,
35
40
  }),
36
- receive(model, props, options = {}) {
41
+ receive(snapshot, input, options = {}) {
42
+ const model = snapshot;
43
+ const props = input;
37
44
  const key = definition.key(props);
38
45
  if (key === model.key) {
39
- const shared = shareValue(model.props, props);
46
+ const shared = shareData(model.props, props);
40
47
  const next = shared === model.props ? model : { ...model, props: shared };
41
48
  return options.refresh ? request(next, false) : { model: next };
42
49
  }
@@ -47,9 +54,10 @@ export function pages(definition) {
47
54
  append: false,
48
55
  result: AsyncResult.initial(),
49
56
  };
50
- return key === undefined ? { model: next, cancel: ['page'] } : request(next, false);
57
+ return key === undefined ? { model: next, cancel: [commandPage] } : request(next, false);
51
58
  },
52
- update(model, message) {
59
+ update(snapshot, message) {
60
+ const model = snapshot;
53
61
  switch (message.type) {
54
62
  case 'More':
55
63
  return request(model, true);
@@ -68,7 +76,7 @@ export function pages(definition) {
68
76
  };
69
77
  case 'Loaded': {
70
78
  const previous = message.append ? available(model.result) : undefined;
71
- const known = new Set(previous?.items.map(definition.itemKey));
79
+ const known = new Set(previous?.items.map((item) => definition.itemKey(item)));
72
80
  const incoming = message.page.items.filter((item) => {
73
81
  const key = definition.itemKey(item);
74
82
  if (known.has(key))
package/dist/program.d.ts CHANGED
@@ -1,9 +1,20 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
2
  import { Cause, Effect, Stream } from 'effect';
3
3
  export type Send<Message> = (message: Message) => void;
4
+ /** queue retains FIFO requests; latest-queued retains only the newest pending request. */
5
+ export type TaskPolicy = 'replace' | 'drop' | 'parallel' | 'queue' | 'latest-queued';
6
+ declare const slotType: unique symbol;
7
+ /** Stable operation identity. Equal diagnostic names never share cancellation. */
8
+ export type CommandSlot = symbol & {
9
+ readonly [slotType]: true;
10
+ };
11
+ export declare const commandSlot: (name: string) => CommandSlot;
4
12
  /** A slot owns either one completion or a stream of progress/events, canceled together. */
5
13
  export type Command<Message, R = never> = {
6
- readonly slot: string;
14
+ readonly slot: CommandSlot;
15
+ readonly policy: TaskPolicy;
16
+ /** Notification for work that never started. */
17
+ readonly onDiscard?: (reason: 'Cancelled' | 'Superseded' | 'Dropped') => void;
7
18
  } & ({
8
19
  readonly effect: Effect.Effect<Message, never, R>;
9
20
  readonly stream?: never;
@@ -18,32 +29,41 @@ export type Command<Message, R = never> = {
18
29
  readonly stream?: never;
19
30
  });
20
31
  /** A lazy request settles successes, typed failures and defects through one message contract. */
21
- export declare function effectCommand<A, E, Success, Failure, R = never>(slot: string, load: () => Effect.Effect<A, E, R>, handlers: {
32
+ export declare function effectCommand<A, E, Success, Failure, R = never>(slot: CommandSlot, load: () => Effect.Effect<A, E, R>, handlers: {
33
+ readonly policy: TaskPolicy;
22
34
  readonly onSuccess: (value: A) => Success;
23
35
  readonly onFailure: (cause: Cause.Cause<E>) => Failure;
24
36
  }): Command<Success | Failure, R>;
25
37
  /** Owned work with no model result. Failures use the program's error reporter. */
26
- export declare function actionCommand<R = never>(slot: string, action: () => Effect.Effect<unknown, unknown, R>): Command<never, R>;
38
+ export declare function actionCommand<R = never>(slot: CommandSlot, action: () => Effect.Effect<unknown, unknown, R>, policy: TaskPolicy): Command<never, R>;
27
39
  export declare function mapCommand<A, B, R = never>(command: Command<A, R>, map: (message: A) => B): Command<B, R>;
28
40
  export interface Transition<Model, Message, R = never> {
29
- readonly model: Model;
41
+ readonly model: Model | Snapshot<Model>;
30
42
  readonly commands?: readonly Command<Message, R>[];
31
- readonly cancel?: readonly string[];
43
+ readonly cancel?: readonly CommandSlot[];
32
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>;
33
49
  export interface Program<Model, Message> {
34
50
  readonly model: () => Snapshot<Model>;
35
51
  readonly send: Send<Message>;
36
52
  readonly subscribe: (listener: (model: Snapshot<Model>) => void) => () => void;
37
53
  readonly dispose: () => void;
54
+ readonly close?: () => Promise<void>;
38
55
  }
39
56
  export interface RunningProgram<Model, Message> extends Program<Model, Message> {
40
- readonly activeSlots: () => readonly string[];
41
- readonly awaitIdle: (slot?: string) => Promise<void>;
57
+ /** Wait for interrupted work and its finalizers as well as admitted work. */
58
+ readonly awaitStopped: () => Promise<void>;
59
+ readonly close: () => Promise<void>;
60
+ readonly activeSlots: () => readonly CommandSlot[];
61
+ readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
42
62
  }
43
63
  export declare function program<Model, Message>(options: {
44
- initial: Model;
64
+ initial: Model | Snapshot<Model>;
45
65
  name?: string;
46
- checkSnapshots?: boolean;
47
66
  update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message>;
48
67
  onDefect?: (cause: unknown) => void;
49
68
  }): RunningProgram<Model, Message>;
69
+ export {};
package/dist/program.js CHANGED
@@ -1,31 +1,45 @@
1
- import { protectSnapshot, checkSnapshotsByDefault } from './snapshot.js';
1
+ import { protectSnapshot } from './snapshot.js';
2
2
  import { reportError, reportSafely } from './errors.js';
3
3
  import { traceProgram, nextProgramId, hasProgramObservers } from './diagnostics.js';
4
4
  import { Cause, Effect, Fiber, Option, Stream } from 'effect';
5
+ export const commandSlot = (name) => Symbol(name);
6
+ const taskPolicies = ['replace', 'drop', 'parallel', 'queue', 'latest-queued'];
5
7
  /** A lazy request settles successes, typed failures and defects through one message contract. */
6
8
  export function effectCommand(slot, load, handlers) {
7
- return { slot, effect: Effect.suspend(load).pipe(Effect.matchCause(handlers)) };
9
+ return {
10
+ slot,
11
+ policy: handlers.policy,
12
+ effect: Effect.suspend(load).pipe(Effect.matchCause(handlers)),
13
+ };
8
14
  }
9
15
  /** Owned work with no model result. Failures use the program's error reporter. */
10
- export function actionCommand(slot, action) {
11
- return { slot, action: Effect.suspend(action).pipe(Effect.asVoid) };
16
+ export function actionCommand(slot, action, policy) {
17
+ return { slot, policy, action: Effect.suspend(action).pipe(Effect.asVoid) };
12
18
  }
13
19
  export function mapCommand(command, map) {
14
20
  if (command.action)
15
- return { slot: command.slot, action: command.action };
21
+ return { ...command, action: command.action };
16
22
  return command.stream
17
- ? { slot: command.slot, stream: command.stream.pipe(Stream.map(map)) }
18
- : { slot: command.slot, effect: command.effect.pipe(Effect.map(map)) };
23
+ ? { ...command, stream: command.stream.pipe(Stream.map(map)) }
24
+ : { ...command, effect: command.effect.pipe(Effect.map(map)) };
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
+ };
19
34
  }
20
35
  export function program(options) {
21
- const checkSnapshots = options.checkSnapshots ?? checkSnapshotsByDefault;
22
36
  const id = nextProgramId();
23
37
  const trace = (kind, slot, message) => hasProgramObservers() &&
24
38
  traceProgram({
25
39
  program: id,
26
40
  ...(options.name ? { name: options.name } : {}),
27
41
  kind,
28
- ...(slot ? { slot } : {}),
42
+ ...(slot ? { slot: slot.description ?? 'command' } : {}),
29
43
  ...(message &&
30
44
  typeof message === 'object' &&
31
45
  'type' in message &&
@@ -35,6 +49,7 @@ export function program(options) {
35
49
  });
36
50
  const waiters = new Set();
37
51
  const notify = () => {
52
+ notifyStopped();
38
53
  for (const waiter of waiters) {
39
54
  if (disposed ||
40
55
  (!draining && (waiter.slot ? !running.has(waiter.slot) : running.size === 0))) {
@@ -45,79 +60,199 @@ export function program(options) {
45
60
  };
46
61
  // A program publishes one immutable value. It needs no reactive dependency graph;
47
62
  // Effect still owns all command fibers, streams, and cancellation below.
48
- let current = protectSnapshot(options.initial, checkSnapshots);
63
+ let current = protectSnapshot(options.initial);
49
64
  const listeners = new Set();
50
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));
51
78
  let disposed = false;
52
79
  let draining = false;
53
80
  const queue = [];
54
- const cancel = (slot) => {
81
+ const deferred = [];
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') => {
93
+ // A synchronous Effect can enqueue completion behind a reset/replacement message.
94
+ // Its fiber is already done, but cancellation still owns that unpublished completion.
95
+ for (let index = queue.length - 1; index >= 0; index--)
96
+ if (queue[index].slot === slot)
97
+ queue.splice(index, 1);
55
98
  const previous = running.get(slot);
56
99
  running.delete(slot);
57
- if (previous)
100
+ if (previous) {
58
101
  trace('cancel', slot);
59
- if (previous?.fiber)
60
- Effect.runFork(Fiber.interrupt(previous.fiber));
102
+ const pending = previous.pending.splice(0);
103
+ for (const command of pending)
104
+ discard(command, reason);
105
+ for (const task of previous.active) {
106
+ if (task.fiber)
107
+ Effect.runFork(Fiber.interrupt(task.fiber));
108
+ else if (task.command && !live.has(task))
109
+ discard(task.command, reason);
110
+ }
111
+ previous.active.clear();
112
+ }
113
+ };
114
+ const ready = [];
115
+ let launching = false;
116
+ const launch = (command, group, task) => {
117
+ ready.push({ command, group, task });
118
+ if (launching)
119
+ return;
120
+ launching = true;
121
+ try {
122
+ while (ready.length) {
123
+ const next = ready.shift();
124
+ start(next.command, next.group, next.task);
125
+ }
126
+ }
127
+ finally {
128
+ launching = false;
129
+ }
61
130
  };
62
- const send = (message) => {
131
+ const start = (command, group, task) => {
132
+ const valid = () => !disposed && running.get(command.slot) === group && group.active.has(task);
133
+ if (!valid())
134
+ return;
135
+ trace('start', command.slot);
136
+ const effect = command.stream
137
+ ? command.stream.pipe(Stream.runForEach((message) => Effect.sync(() => {
138
+ if (valid())
139
+ enqueue(message, command.slot);
140
+ })), Effect.as(Option.none()))
141
+ : command.action
142
+ ? command.action.pipe(Effect.as(Option.none()))
143
+ : command.effect.pipe(Effect.map(Option.some));
144
+ live.add(task);
145
+ const fiber = Effect.runFork(effect);
146
+ task.fiber = fiber;
147
+ fiber.addObserver((exit) => {
148
+ live.delete(task);
149
+ notifyStopped();
150
+ if (!valid())
151
+ return;
152
+ group.active.delete(task);
153
+ if (!group.active.size && !group.pending.length)
154
+ running.delete(command.slot);
155
+ trace(exit._tag === 'Success' ? 'complete' : 'defect', command.slot);
156
+ if (exit._tag === 'Success') {
157
+ if (Option.isSome(exit.value))
158
+ enqueue(exit.value.value, command.slot);
159
+ }
160
+ else
161
+ reportSafely(options.onDefect ?? reportError, exit.cause);
162
+ if (!disposed && running.get(command.slot) === group && !group.active.size) {
163
+ // Process earlier reducer messages and completion subscribers before choosing the
164
+ // next write, so cancellation and latest-queued still apply to all pending work.
165
+ if (draining)
166
+ deferred.push({ slot: command.slot, group });
167
+ else
168
+ advance(command.slot, group);
169
+ }
170
+ notify();
171
+ });
172
+ if (!valid())
173
+ Effect.runFork(Fiber.interrupt(fiber));
174
+ };
175
+ const advance = (slot, group) => {
176
+ if (disposed || running.get(slot) !== group || group.active.size)
177
+ return;
178
+ const next = group.pending.shift();
179
+ if (!next)
180
+ return;
181
+ const task = { command: next };
182
+ group.active.add(task);
183
+ launch(next, group, task);
184
+ };
185
+ const enqueue = (message, slot) => {
63
186
  if (disposed)
64
187
  return;
65
- queue.push(message);
188
+ queue.push(slot === undefined ? { message } : { message, slot });
66
189
  if (draining)
67
190
  return;
68
191
  draining = true;
69
192
  try {
70
- while (queue.length && !disposed) {
71
- const message = queue.shift();
193
+ while ((queue.length || deferred.length) && !disposed) {
194
+ if (!queue.length) {
195
+ const next = deferred.shift();
196
+ advance(next.slot, next.group);
197
+ continue;
198
+ }
199
+ const { message } = queue.shift();
72
200
  const transition = options.update(current, message);
201
+ for (const command of transition.commands ?? []) {
202
+ if (typeof command.slot !== 'symbol' || !taskPolicies.includes(command.policy))
203
+ throw new TypeError('Commands require a commandSlot() token and an explicit concurrency policy.');
204
+ }
73
205
  trace('update', undefined, message);
74
206
  for (const slot of transition.cancel ?? [])
75
207
  cancel(slot);
76
- const next = protectSnapshot(transition.model, checkSnapshots);
208
+ const next = protectSnapshot(transition.model);
77
209
  if (!Object.is(current, next)) {
78
210
  current = next;
79
211
  for (const listener of listeners)
80
212
  listener(current);
81
213
  }
214
+ const starts = [];
215
+ // Admit the entire transition before launching Effects, so a later replacement can
216
+ // supersede earlier work in the same transaction without executing it.
82
217
  for (const command of transition.commands ?? []) {
83
218
  if (disposed)
84
219
  break;
85
- cancel(command.slot);
86
- const token = {};
87
- const runningCommand = { token };
88
- running.set(command.slot, runningCommand);
89
- trace('start', command.slot);
90
- const effect = command.stream
91
- ? command.stream.pipe(Stream.runForEach((message) => Effect.sync(() => {
92
- if (!disposed && running.get(command.slot)?.token === token)
93
- send(message);
94
- })), Effect.as(Option.none()))
95
- : command.action
96
- ? command.action.pipe(Effect.as(Option.none()))
97
- : command.effect.pipe(Effect.map(Option.some));
98
- const fiber = Effect.runFork(effect);
99
- runningCommand.fiber = fiber;
100
- if (disposed) {
101
- Effect.runFork(Fiber.interrupt(fiber));
102
- break;
220
+ const policy = command.policy;
221
+ let group = running.get(command.slot);
222
+ if (policy === 'drop' && group) {
223
+ discard(command, 'Dropped');
224
+ continue;
225
+ }
226
+ if (policy === 'replace') {
227
+ cancel(command.slot, 'Superseded');
228
+ group = undefined;
229
+ }
230
+ if (group && (policy === 'queue' || policy === 'latest-queued')) {
231
+ if (policy === 'latest-queued')
232
+ for (const pending of group.pending.splice(0))
233
+ discard(pending, 'Superseded');
234
+ group.pending.push(command);
235
+ continue;
103
236
  }
104
- fiber.addObserver((exit) => {
105
- if (disposed || running.get(command.slot)?.token !== token)
106
- return;
107
- running.delete(command.slot);
108
- trace(exit._tag === 'Success' ? 'complete' : 'defect', command.slot);
109
- if (exit._tag === 'Success') {
110
- if (Option.isSome(exit.value))
111
- send(exit.value.value);
112
- }
113
- else
114
- reportSafely(options.onDefect ?? reportError, exit.cause);
115
- notify();
116
- });
237
+ if (!group) {
238
+ group = { active: new Set(), pending: [] };
239
+ running.set(command.slot, group);
240
+ }
241
+ const task = { command };
242
+ group.active.add(task);
243
+ const admitted = group;
244
+ starts.push(() => launch(command, admitted, task));
117
245
  }
246
+ for (const launch of starts)
247
+ launch();
118
248
  }
119
249
  }
120
250
  catch (error) {
251
+ // A failed reducer must not leave deferred work keeping awaitIdle pending.
252
+ for (const { slot, group } of deferred)
253
+ if (running.get(slot) === group)
254
+ cancel(slot);
255
+ deferred.length = 0;
121
256
  queue.length = 0;
122
257
  throw error;
123
258
  }
@@ -126,7 +261,25 @@ export function program(options) {
126
261
  notify();
127
262
  }
128
263
  };
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
+ };
129
277
  return {
278
+ awaitStopped,
279
+ close: () => {
280
+ dispose();
281
+ return awaitStopped();
282
+ },
130
283
  model: () => current,
131
284
  activeSlots: () => [...running.keys()],
132
285
  awaitIdle: (slot) => disposed || (!draining && (slot ? !running.has(slot) : running.size === 0))
@@ -151,16 +304,6 @@ export function program(options) {
151
304
  listeners.delete(receive);
152
305
  };
153
306
  },
154
- dispose() {
155
- if (disposed)
156
- return;
157
- disposed = true;
158
- queue.length = 0;
159
- for (const slot of running.keys())
160
- cancel(slot);
161
- listeners.clear();
162
- trace('dispose');
163
- notify();
164
- },
307
+ dispose,
165
308
  };
166
309
  }
@@ -0,0 +1,13 @@
1
+ import type { Effect } from 'effect';
2
+ import type { Snapshot } from './snapshot.js';
3
+ import type { Query, QueryGroup } from './query.js';
4
+ export interface QueryDefinition<Args, A, E, R> {
5
+ readonly name: string;
6
+ readonly load: (args: Snapshot<Args>, previous?: Snapshot<A>) => Effect.Effect<A, E, R>;
7
+ readonly groups?: readonly QueryGroup[];
8
+ readonly unused?: 'retain' | 'cancel';
9
+ readonly staleTime: number;
10
+ readonly share?: (previous: Snapshot<A>, next: A | Snapshot<A>) => A | Snapshot<A>;
11
+ }
12
+ export declare function registerQuery<Args, A, E, R>(query: Query<Args, A, E, R>, definition: QueryDefinition<Args, A, E, R>): void;
13
+ export declare function queryDefinition<Args, A, E, R>(query: Query<Args, A, E, R>): QueryDefinition<Args, A, E, R>;
@@ -0,0 +1,10 @@
1
+ const definitions = new WeakMap();
2
+ export function registerQuery(query, definition) {
3
+ definitions.set(query, definition);
4
+ }
5
+ export function queryDefinition(query) {
6
+ const definition = definitions.get(query);
7
+ if (!definition)
8
+ throw new TypeError('Use query() to create a query definition.');
9
+ return definition;
10
+ }
package/dist/query.d.ts CHANGED
@@ -1,25 +1,36 @@
1
1
  import type { Effect } from 'effect';
2
+ import { type QueryDefinition } from './query-internals.js';
3
+ /** Canonical data identity: plain objects, dense arrays, and finite scalar values. */
4
+ export type QueryKey = string | number | boolean | null | undefined | readonly QueryKey[] | {
5
+ readonly [key: string]: QueryKey;
6
+ };
7
+ /** Shared by cache lookup and resource reconciliation. Object property order is irrelevant. */
8
+ export declare function encodeQueryKey(key: QueryKey): string;
9
+ /** Data-only request arguments. Services belong in the Effect environment. */
10
+ export type QueryArgs<A> = A extends string | number | boolean | null | undefined ? A : A extends (...args: never[]) => unknown ? never : A extends object ? {
11
+ readonly [K in keyof A]: K extends symbol ? never : QueryArgs<A[K]>;
12
+ } : never;
13
+ declare const queryType: unique symbol;
14
+ /** An opaque typed definition. Every request argument participates in cache identity. */
2
15
  export interface Query<Args, A, E = never, R = never> {
3
- readonly id: number;
4
16
  readonly name: string;
5
- readonly key: (args: Args) => string;
6
- readonly load: (args: Args) => Effect.Effect<A, E, R>;
7
- /** Revalidate on activation/prefetch after this many milliseconds. No polling timer. */
8
- readonly staleTime: number;
9
- /** Reuse unchanged domain entities before publishing a refreshed result. */
10
- readonly share?: (previous: A, next: A) => A;
17
+ readonly [queryType]: (args: Args) => Effect.Effect<A, E, R>;
11
18
  }
12
- /** Identity belongs to arguments, never callback identity. Separate caches isolate independent data scopes. */
13
- export declare function query<A, E = never, R = never>(definition: {
14
- readonly name: string;
15
- readonly load: () => Effect.Effect<A, E, R>;
19
+ type Definition<Args, A, E, R> = Omit<QueryDefinition<Args, A, E, R>, 'staleTime'> & {
16
20
  readonly staleTime?: number;
17
- readonly share?: (previous: A, next: A) => A;
21
+ /** Custom projections can silently reuse a different request's result. */
22
+ readonly key?: never;
23
+ };
24
+ export declare function query<Args, A, E = never, R = never>(definition: Definition<Args, A, E, R> & (Args extends QueryArgs<Args> ? unknown : {
25
+ readonly nonSerializableQueryArguments: never;
26
+ })): Query<Args, A, E, R>;
27
+ export declare function query<A, E = never, R = never>(definition: Definition<true, A, E, R> & {
28
+ readonly load: () => Effect.Effect<A, E, R>;
18
29
  }): Query<true, A, E, R>;
19
- export declare function query<Args, A, E = never, R = never>(definition: {
20
- readonly name: string;
21
- readonly key: (args: Args) => string;
22
- readonly load: (args: Args) => Effect.Effect<A, E, R>;
23
- readonly staleTime?: number;
24
- readonly share?: (previous: A, next: A) => A;
25
- }): Query<Args, 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;
36
+ export {};
package/dist/query.js CHANGED
@@ -1,12 +1,71 @@
1
- let nextQueryId = 0;
1
+ import { registerQuery } from './query-internals.js';
2
+ /** Shared by cache lookup and resource reconciliation. Object property order is irrelevant. */
3
+ export function encodeQueryKey(key) {
4
+ const ancestors = new Set();
5
+ const encode = (value) => {
6
+ if (value === undefined)
7
+ return 'u';
8
+ if (value === null)
9
+ return 'n';
10
+ if (typeof value === 'string')
11
+ return `s${JSON.stringify(value)}`;
12
+ if (typeof value === 'boolean')
13
+ return value ? 'b1' : 'b0';
14
+ if (typeof value === 'number') {
15
+ if (!Number.isFinite(value))
16
+ throw new TypeError('Query keys require finite numbers.');
17
+ return Object.is(value, -0) ? 'd-0' : `d${value}`;
18
+ }
19
+ if (typeof value !== 'object')
20
+ throw new TypeError('Unsupported query key value.');
21
+ if (ancestors.has(value))
22
+ throw new TypeError('Query keys cannot contain cycles.');
23
+ const prototype = Object.getPrototypeOf(value);
24
+ if (Array.isArray(value)
25
+ ? prototype !== Array.prototype
26
+ : prototype !== Object.prototype && prototype !== null)
27
+ throw new TypeError('Query keys require plain objects and arrays.');
28
+ ancestors.add(value);
29
+ try {
30
+ const descriptors = Object.getOwnPropertyDescriptors(value);
31
+ if (Reflect.ownKeys(value).some((key) => typeof key === 'symbol'))
32
+ throw new TypeError('Query keys cannot contain symbol properties.');
33
+ for (const [name, descriptor] of Object.entries(descriptors)) {
34
+ if (Array.isArray(value) && name === 'length')
35
+ continue;
36
+ if (!descriptor.enumerable || !('value' in descriptor))
37
+ throw new TypeError('Query keys require enumerable data properties.');
38
+ }
39
+ if (Array.isArray(value)) {
40
+ if (Object.keys(value).length !== value.length ||
41
+ Object.keys(value).some((key, index) => key !== String(index)))
42
+ throw new TypeError('Query keys require dense arrays without extra properties.');
43
+ return `a[${value.map(encode).join(',')}]`;
44
+ }
45
+ return `o{${Object.keys(value)
46
+ .sort()
47
+ .map((key) => `${JSON.stringify(key)}:${encode(descriptors[key].value)}`)
48
+ .join(',')}}`;
49
+ }
50
+ finally {
51
+ ancestors.delete(value);
52
+ }
53
+ };
54
+ return encode(key);
55
+ }
2
56
  export function query(definition) {
57
+ if ('key' in definition)
58
+ throw new TypeError('Query identity includes all arguments. Remove key and provide services through the Effect environment.');
3
59
  const staleTime = definition.staleTime ?? Infinity;
4
60
  if (Number.isNaN(staleTime) || staleTime < 0)
5
61
  throw new RangeError('Query staleTime must be nonnegative.');
6
- return Object.freeze({
62
+ const result = Object.freeze({ name: definition.name });
63
+ registerQuery(result, Object.freeze({
7
64
  ...definition,
8
- key: definition.key ?? (() => ''),
9
- id: ++nextQueryId,
65
+ ...(definition.groups ? { groups: Object.freeze([...definition.groups]) } : {}),
10
66
  staleTime,
11
- });
67
+ }));
68
+ return result;
12
69
  }
70
+ /** Invalidation identity; equal diagnostic names do not share membership. */
71
+ export const queryGroup = (name) => Symbol(name);