effectweb 0.2.3 → 0.3.0

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 (62) hide show
  1. package/README.md +93 -1
  2. package/dist/AsyncContent.d.ts +2 -2
  3. package/dist/AsyncContent.js +6 -3
  4. package/dist/actions.d.ts +4 -3
  5. package/dist/actions.js +1 -0
  6. package/dist/cache-internals.d.ts +14 -0
  7. package/dist/cache-internals.js +11 -0
  8. package/dist/cache.d.ts +17 -14
  9. package/dist/cache.js +98 -26
  10. package/dist/collection.d.ts +10 -9
  11. package/dist/collection.js +32 -6
  12. package/dist/component.d.ts +8 -7
  13. package/dist/diagnostics.d.ts +25 -0
  14. package/dist/diagnostics.js +167 -1
  15. package/dist/dom-attributes.json +71 -0
  16. package/dist/dom.d.ts +33 -12
  17. package/dist/dom.js +466 -80
  18. package/dist/form.d.ts +61 -0
  19. package/dist/form.js +98 -0
  20. package/dist/html-attributes.d.ts +602 -0
  21. package/dist/html-attributes.js +1 -0
  22. package/dist/http.d.ts +5 -0
  23. package/dist/http.js +7 -0
  24. package/dist/index.d.ts +12 -10
  25. package/dist/index.js +7 -5
  26. package/dist/jsx.d.ts +165 -33
  27. package/dist/jsx.js +2 -1
  28. package/dist/lazy.d.ts +23 -0
  29. package/dist/lazy.js +58 -0
  30. package/dist/load.d.ts +3 -3
  31. package/dist/mount.d.ts +8 -11
  32. package/dist/mount.js +1 -3
  33. package/dist/owner.d.ts +11 -9
  34. package/dist/owner.js +27 -36
  35. package/dist/pages.d.ts +23 -22
  36. package/dist/pages.js +15 -9
  37. package/dist/program.d.ts +19 -9
  38. package/dist/program.js +145 -49
  39. package/dist/query-internals.d.ts +11 -0
  40. package/dist/query-internals.js +10 -0
  41. package/dist/query.d.ts +24 -19
  42. package/dist/query.js +60 -7
  43. package/dist/resource.d.ts +4 -10
  44. package/dist/resource.js +14 -5
  45. package/dist/runtime.d.ts +1 -2
  46. package/dist/runtime.js +3 -3
  47. package/dist/session.d.ts +12 -15
  48. package/dist/session.js +15 -12
  49. package/dist/share.d.ts +5 -4
  50. package/dist/share.js +5 -44
  51. package/dist/sharing.d.ts +6 -0
  52. package/dist/sharing.js +93 -0
  53. package/dist/snapshot.d.ts +16 -5
  54. package/dist/snapshot.js +6 -7
  55. package/dist/svg-attributes.d.ts +12 -0
  56. package/dist/svg-attributes.js +1 -0
  57. package/dist/task.d.ts +9 -8
  58. package/dist/task.js +75 -47
  59. package/dist/tasks.d.ts +20 -15
  60. package/dist/tasks.js +39 -25
  61. package/dist/testing.d.ts +10 -14
  62. package/package.json +5 -1
package/dist/pages.js CHANGED
@@ -1,8 +1,10 @@
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';
5
6
  import { available } from './resource.js';
7
+ const commandPage = commandSlot('page');
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.
@@ -19,7 +21,8 @@ export function pages(definition) {
19
21
  return {
20
22
  model: { ...model, append, result: AsyncResult.waiting(model.result) },
21
23
  commands: [
22
- effectCommand('page', () => definition.load(model.props, cursor), {
24
+ effectCommand(commandPage, () => definition.load(model.props, cursor), {
25
+ policy: 'replace',
23
26
  onSuccess: (page) => ({ type: 'Loaded', page, append }),
24
27
  onFailure: (cause) => ({ type: 'Failed', cause }),
25
28
  }),
@@ -28,15 +31,17 @@ export function pages(definition) {
28
31
  };
29
32
  const pagination = {
30
33
  init: (props) => ({
31
- props,
34
+ props: props,
32
35
  key: undefined,
33
36
  result: AsyncResult.initial(),
34
37
  append: false,
35
38
  }),
36
- receive(model, props, options = {}) {
39
+ receive(snapshot, input, options = {}) {
40
+ const model = snapshot;
41
+ const props = input;
37
42
  const key = definition.key(props);
38
43
  if (key === model.key) {
39
- const shared = shareValue(model.props, props);
44
+ const shared = shareData(model.props, props);
40
45
  const next = shared === model.props ? model : { ...model, props: shared };
41
46
  return options.refresh ? request(next, false) : { model: next };
42
47
  }
@@ -47,9 +52,10 @@ export function pages(definition) {
47
52
  append: false,
48
53
  result: AsyncResult.initial(),
49
54
  };
50
- return key === undefined ? { model: next, cancel: ['page'] } : request(next, false);
55
+ return key === undefined ? { model: next, cancel: [commandPage] } : request(next, false);
51
56
  },
52
- update(model, message) {
57
+ update(snapshot, message) {
58
+ const model = snapshot;
53
59
  switch (message.type) {
54
60
  case 'More':
55
61
  return request(model, true);
@@ -68,7 +74,7 @@ export function pages(definition) {
68
74
  };
69
75
  case 'Loaded': {
70
76
  const previous = message.append ? available(model.result) : undefined;
71
- const known = new Set(previous?.items.map(definition.itemKey));
77
+ const known = new Set(previous?.items.map((item) => definition.itemKey(item)));
72
78
  const incoming = message.page.items.filter((item) => {
73
79
  const key = definition.itemKey(item);
74
80
  if (known.has(key))
package/dist/program.d.ts CHANGED
@@ -1,9 +1,18 @@
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;
7
16
  } & ({
8
17
  readonly effect: Effect.Effect<Message, never, R>;
9
18
  readonly stream?: never;
@@ -18,17 +27,18 @@ export type Command<Message, R = never> = {
18
27
  readonly stream?: never;
19
28
  });
20
29
  /** 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: {
30
+ export declare function effectCommand<A, E, Success, Failure, R = never>(slot: CommandSlot, load: () => Effect.Effect<A, E, R>, handlers: {
31
+ readonly policy: TaskPolicy;
22
32
  readonly onSuccess: (value: A) => Success;
23
33
  readonly onFailure: (cause: Cause.Cause<E>) => Failure;
24
34
  }): Command<Success | Failure, R>;
25
35
  /** 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>;
36
+ export declare function actionCommand<R = never>(slot: CommandSlot, action: () => Effect.Effect<unknown, unknown, R>, policy: TaskPolicy): Command<never, R>;
27
37
  export declare function mapCommand<A, B, R = never>(command: Command<A, R>, map: (message: A) => B): Command<B, R>;
28
38
  export interface Transition<Model, Message, R = never> {
29
- readonly model: Model;
39
+ readonly model: Model | Snapshot<Model>;
30
40
  readonly commands?: readonly Command<Message, R>[];
31
- readonly cancel?: readonly string[];
41
+ readonly cancel?: readonly CommandSlot[];
32
42
  }
33
43
  export interface Program<Model, Message> {
34
44
  readonly model: () => Snapshot<Model>;
@@ -37,13 +47,13 @@ export interface Program<Model, Message> {
37
47
  readonly dispose: () => void;
38
48
  }
39
49
  export interface RunningProgram<Model, Message> extends Program<Model, Message> {
40
- readonly activeSlots: () => readonly string[];
41
- readonly awaitIdle: (slot?: string) => Promise<void>;
50
+ readonly activeSlots: () => readonly CommandSlot[];
51
+ readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
42
52
  }
43
53
  export declare function program<Model, Message>(options: {
44
- initial: Model;
54
+ initial: Model | Snapshot<Model>;
45
55
  name?: string;
46
- checkSnapshots?: boolean;
47
56
  update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message>;
48
57
  onDefect?: (cause: unknown) => void;
49
58
  }): RunningProgram<Model, Message>;
59
+ export {};
package/dist/program.js CHANGED
@@ -1,31 +1,36 @@
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)) };
19
25
  }
20
26
  export function program(options) {
21
- const checkSnapshots = options.checkSnapshots ?? checkSnapshotsByDefault;
22
27
  const id = nextProgramId();
23
28
  const trace = (kind, slot, message) => hasProgramObservers() &&
24
29
  traceProgram({
25
30
  program: id,
26
31
  ...(options.name ? { name: options.name } : {}),
27
32
  kind,
28
- ...(slot ? { slot } : {}),
33
+ ...(slot ? { slot: slot.description ?? 'command' } : {}),
29
34
  ...(message &&
30
35
  typeof message === 'object' &&
31
36
  'type' in message &&
@@ -45,79 +50,168 @@ export function program(options) {
45
50
  };
46
51
  // A program publishes one immutable value. It needs no reactive dependency graph;
47
52
  // Effect still owns all command fibers, streams, and cancellation below.
48
- let current = protectSnapshot(options.initial, checkSnapshots);
53
+ let current = protectSnapshot(options.initial);
49
54
  const listeners = new Set();
50
55
  const running = new Map();
51
56
  let disposed = false;
52
57
  let draining = false;
53
58
  const queue = [];
59
+ const deferred = [];
54
60
  const cancel = (slot) => {
61
+ // A synchronous Effect can enqueue completion behind a reset/replacement message.
62
+ // Its fiber is already done, but cancellation still owns that unpublished completion.
63
+ for (let index = queue.length - 1; index >= 0; index--)
64
+ if (queue[index].slot === slot)
65
+ queue.splice(index, 1);
55
66
  const previous = running.get(slot);
56
67
  running.delete(slot);
57
- if (previous)
68
+ if (previous) {
58
69
  trace('cancel', slot);
59
- if (previous?.fiber)
60
- Effect.runFork(Fiber.interrupt(previous.fiber));
70
+ previous.pending.length = 0;
71
+ for (const task of previous.active)
72
+ if (task.fiber)
73
+ Effect.runFork(Fiber.interrupt(task.fiber));
74
+ previous.active.clear();
75
+ }
61
76
  };
62
- const send = (message) => {
77
+ const ready = [];
78
+ let launching = false;
79
+ const launch = (command, group, task) => {
80
+ ready.push({ command, group, task });
81
+ if (launching)
82
+ return;
83
+ launching = true;
84
+ try {
85
+ while (ready.length) {
86
+ const next = ready.shift();
87
+ start(next.command, next.group, next.task);
88
+ }
89
+ }
90
+ finally {
91
+ launching = false;
92
+ }
93
+ };
94
+ const start = (command, group, task) => {
95
+ const valid = () => !disposed && running.get(command.slot) === group && group.active.has(task);
96
+ if (!valid())
97
+ return;
98
+ trace('start', command.slot);
99
+ const effect = command.stream
100
+ ? command.stream.pipe(Stream.runForEach((message) => Effect.sync(() => {
101
+ if (valid())
102
+ enqueue(message, command.slot);
103
+ })), Effect.as(Option.none()))
104
+ : command.action
105
+ ? command.action.pipe(Effect.as(Option.none()))
106
+ : command.effect.pipe(Effect.map(Option.some));
107
+ const fiber = Effect.runFork(effect);
108
+ task.fiber = fiber;
109
+ if (!valid()) {
110
+ Effect.runFork(Fiber.interrupt(fiber));
111
+ return;
112
+ }
113
+ fiber.addObserver((exit) => {
114
+ if (!valid())
115
+ return;
116
+ group.active.delete(task);
117
+ if (!group.active.size && !group.pending.length)
118
+ running.delete(command.slot);
119
+ trace(exit._tag === 'Success' ? 'complete' : 'defect', command.slot);
120
+ if (exit._tag === 'Success') {
121
+ if (Option.isSome(exit.value))
122
+ enqueue(exit.value.value, command.slot);
123
+ }
124
+ else
125
+ reportSafely(options.onDefect ?? reportError, exit.cause);
126
+ if (!disposed && running.get(command.slot) === group && !group.active.size) {
127
+ // Process earlier reducer messages and completion subscribers before choosing the
128
+ // next write, so cancellation and latest-queued still apply to all pending work.
129
+ if (draining)
130
+ deferred.push({ slot: command.slot, group });
131
+ else
132
+ advance(command.slot, group);
133
+ }
134
+ notify();
135
+ });
136
+ };
137
+ const advance = (slot, group) => {
138
+ if (disposed || running.get(slot) !== group || group.active.size)
139
+ return;
140
+ const next = group.pending.shift();
141
+ if (!next)
142
+ return;
143
+ const task = {};
144
+ group.active.add(task);
145
+ launch(next, group, task);
146
+ };
147
+ const enqueue = (message, slot) => {
63
148
  if (disposed)
64
149
  return;
65
- queue.push(message);
150
+ queue.push(slot === undefined ? { message } : { message, slot });
66
151
  if (draining)
67
152
  return;
68
153
  draining = true;
69
154
  try {
70
- while (queue.length && !disposed) {
71
- const message = queue.shift();
155
+ while ((queue.length || deferred.length) && !disposed) {
156
+ if (!queue.length) {
157
+ const next = deferred.shift();
158
+ advance(next.slot, next.group);
159
+ continue;
160
+ }
161
+ const { message } = queue.shift();
72
162
  const transition = options.update(current, message);
163
+ for (const command of transition.commands ?? []) {
164
+ if (typeof command.slot !== 'symbol' || !taskPolicies.includes(command.policy))
165
+ throw new TypeError('Commands require a commandSlot() token and an explicit concurrency policy.');
166
+ }
73
167
  trace('update', undefined, message);
74
168
  for (const slot of transition.cancel ?? [])
75
169
  cancel(slot);
76
- const next = protectSnapshot(transition.model, checkSnapshots);
170
+ const next = protectSnapshot(transition.model);
77
171
  if (!Object.is(current, next)) {
78
172
  current = next;
79
173
  for (const listener of listeners)
80
174
  listener(current);
81
175
  }
176
+ const starts = [];
177
+ // Admit the entire transition before launching Effects, so a later replacement can
178
+ // supersede earlier work in the same transaction without executing it.
82
179
  for (const command of transition.commands ?? []) {
83
180
  if (disposed)
84
181
  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;
182
+ const policy = command.policy;
183
+ let group = running.get(command.slot);
184
+ if (policy === 'drop' && group)
185
+ continue;
186
+ if (policy === 'replace') {
187
+ cancel(command.slot);
188
+ group = undefined;
103
189
  }
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
- });
190
+ if (group && (policy === 'queue' || policy === 'latest-queued')) {
191
+ if (policy === 'latest-queued')
192
+ group.pending.length = 0;
193
+ group.pending.push(command);
194
+ continue;
195
+ }
196
+ if (!group) {
197
+ group = { active: new Set(), pending: [] };
198
+ running.set(command.slot, group);
199
+ }
200
+ const task = {};
201
+ group.active.add(task);
202
+ const admitted = group;
203
+ starts.push(() => launch(command, admitted, task));
117
204
  }
205
+ for (const launch of starts)
206
+ launch();
118
207
  }
119
208
  }
120
209
  catch (error) {
210
+ // A failed reducer must not leave deferred work keeping awaitIdle pending.
211
+ for (const { slot, group } of deferred)
212
+ if (running.get(slot) === group)
213
+ cancel(slot);
214
+ deferred.length = 0;
121
215
  queue.length = 0;
122
216
  throw error;
123
217
  }
@@ -126,6 +220,7 @@ export function program(options) {
126
220
  notify();
127
221
  }
128
222
  };
223
+ const send = (message) => enqueue(message);
129
224
  return {
130
225
  model: () => current,
131
226
  activeSlots: () => [...running.keys()],
@@ -156,6 +251,7 @@ export function program(options) {
156
251
  return;
157
252
  disposed = true;
158
253
  queue.length = 0;
254
+ deferred.length = 0;
159
255
  for (const slot of running.keys())
160
256
  cancel(slot);
161
257
  listeners.clear();
@@ -0,0 +1,11 @@
1
+ import type { Effect } from 'effect';
2
+ import type { Snapshot } from './snapshot.js';
3
+ import type { Query } from './query.js';
4
+ export interface QueryDefinition<Args, A, E, R> {
5
+ readonly name: string;
6
+ readonly load: (args: Snapshot<Args>) => Effect.Effect<A, E, R>;
7
+ readonly staleTime: number;
8
+ readonly share?: (previous: Snapshot<A>, next: A | Snapshot<A>) => A | Snapshot<A>;
9
+ }
10
+ export declare function registerQuery<Args, A, E, R>(query: Query<Args, A, E, R>, definition: QueryDefinition<Args, A, E, R>): void;
11
+ 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,30 @@
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
+ export {};
package/dist/query.js CHANGED
@@ -1,12 +1,65 @@
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({
7
- ...definition,
8
- key: definition.key ?? (() => ''),
9
- id: ++nextQueryId,
10
- staleTime,
11
- });
62
+ const result = Object.freeze({ name: definition.name });
63
+ registerQuery(result, Object.freeze({ ...definition, staleTime }));
64
+ return result;
12
65
  }
@@ -1,4 +1,4 @@
1
- import { Cause } from 'effect';
1
+ import type { Snapshot } from './snapshot.js';
2
2
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
3
  import type { UiLoad } from './load.js';
4
4
  import { type UiRuntime } from './runtime.js';
@@ -8,25 +8,19 @@ export interface ResourceModel<Props, A, E = unknown> {
8
8
  readonly key: string | undefined;
9
9
  readonly result: AsyncResult.AsyncResult<A, E>;
10
10
  }
11
- export type ResourceMessage<A, E = unknown> = {
11
+ export type ResourceMessage = {
12
12
  type: 'Retry';
13
- } | {
14
- type: 'Loaded';
15
- value: A;
16
- } | {
17
- type: 'Failed';
18
- cause: Cause.Cause<E>;
19
13
  };
20
14
  export declare const available: <A>(result: AsyncResult.AsyncResult<A, unknown>) => A | undefined;
21
15
  export declare const resourceError: (result: AsyncResult.AsyncResult<unknown, unknown>) => string;
22
16
  /** A replaceable request owns one result. Key changes clear it; refreshes preserve the last success. */
23
17
  export declare function resourceComponent<Props, A, E = unknown, R = never>(definition: {
24
- request: (props: Props) => {
18
+ request: (props: Snapshot<Props>) => {
25
19
  key: string;
26
20
  load: () => UiLoad<A, E, R>;
27
21
  delay?: number;
28
22
  } | undefined;
29
- view: View<ResourceModel<Props, A, E>, ResourceMessage<A, E>>;
23
+ view: View<ResourceModel<Props, A, E>, ResourceMessage>;
30
24
  } & ([R] extends [never] ? {
31
25
  runtime?: UiRuntime<R>;
32
26
  } : {
package/dist/resource.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { commandSlot } from './program.js';
1
2
  import { Cause, Effect, Option } from 'effect';
2
3
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
4
  import { loadEffect } from './load.js';
4
5
  import { component } from './component.js';
5
6
  import { defaultUiRuntime } from './runtime.js';
6
7
  import { effectCommand } from './program.js';
8
+ const commandLoad = commandSlot('load');
7
9
  export const available = (result) => Option.getOrUndefined(AsyncResult.value(result));
8
10
  export const resourceError = (result) => {
9
11
  if (!AsyncResult.isFailure(result))
@@ -21,7 +23,7 @@ export function resourceComponent(definition) {
21
23
  if (!selected)
22
24
  return {
23
25
  model: { ...model, key: undefined, result: AsyncResult.initial() },
24
- cancel: ['load'],
26
+ cancel: [commandLoad],
25
27
  };
26
28
  return {
27
29
  model: {
@@ -30,9 +32,10 @@ export function resourceComponent(definition) {
30
32
  result: AsyncResult.waiting(selected.key === model.key ? model.result : AsyncResult.initial()),
31
33
  },
32
34
  commands: [
33
- effectCommand('load', () => runtime.provide(selected.delay
35
+ effectCommand(commandLoad, () => runtime.provide(selected.delay
34
36
  ? Effect.sleep(selected.delay).pipe(Effect.andThen(loadEffect(selected.load)))
35
37
  : loadEffect(selected.load)), {
38
+ policy: 'replace',
36
39
  onSuccess: (value) => ({ type: 'Loaded', value }),
37
40
  onFailure: (cause) => ({ type: 'Failed', cause }),
38
41
  }),
@@ -40,9 +43,15 @@ export function resourceComponent(definition) {
40
43
  };
41
44
  };
42
45
  return component({
43
- init: (props) => ({ props, key: undefined, result: AsyncResult.initial() }),
44
- receive: (model, props) => request({ ...model, props }),
45
- update: (model, message) => {
46
+ init: (props) => ({
47
+ props: props,
48
+ key: undefined,
49
+ result: AsyncResult.initial(),
50
+ }),
51
+ receive: (model, props) => request({ ...model, props: props }),
52
+ update: (snapshot, message) => {
53
+ // Internal commands preserve domain types; the public reducer boundary is immutable.
54
+ const model = snapshot;
46
55
  switch (message.type) {
47
56
  case 'Retry':
48
57
  return request(model, true);
package/dist/runtime.d.ts CHANGED
@@ -7,10 +7,9 @@ export interface UiRuntime<R> {
7
7
  readonly provide: <A, E>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>;
8
8
  readonly command: <M>(command: Command<M, R>) => Command<M>;
9
9
  readonly program: <M, Msg>(options: {
10
- initial: M;
10
+ initial: M | Snapshot<M>;
11
11
  update: (model: Snapshot<M>, message: Msg) => Transition<M, Msg, R>;
12
12
  name?: string;
13
- checkSnapshots?: boolean;
14
13
  onDefect?: (cause: unknown) => void;
15
14
  }) => RunningProgram<M, Msg>;
16
15
  }
package/dist/runtime.js CHANGED
@@ -3,10 +3,10 @@ import { program } from './program.js';
3
3
  export function uiRuntime(context) {
4
4
  const provide = (effect) => Effect.provideContext(effect, context);
5
5
  const command = (value) => value.stream
6
- ? { slot: value.slot, stream: value.stream.pipe(Stream.provideContext(context)) }
6
+ ? { ...value, stream: value.stream.pipe(Stream.provideContext(context)) }
7
7
  : value.action
8
- ? { slot: value.slot, action: provide(value.action) }
9
- : { slot: value.slot, effect: provide(value.effect) };
8
+ ? { ...value, action: provide(value.action) }
9
+ : { ...value, effect: provide(value.effect) };
10
10
  return {
11
11
  context,
12
12
  provide,