effectweb 0.3.0 → 0.4.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 (59) hide show
  1. package/README.md +35 -4
  2. package/dist/AsyncContent.d.ts +1 -1
  3. package/dist/AsyncContent.js +18 -35
  4. package/dist/boundary.d.ts +11 -0
  5. package/dist/boundary.js +66 -0
  6. package/dist/cache-internals.d.ts +4 -0
  7. package/dist/cache.d.ts +21 -5
  8. package/dist/cache.js +178 -25
  9. package/dist/collection.d.ts +1 -1
  10. package/dist/collection.js +1 -3
  11. package/dist/component.d.ts +3 -0
  12. package/dist/component.js +73 -10
  13. package/dist/dom.d.ts +35 -24
  14. package/dist/dom.js +177 -128
  15. package/dist/effectEvent.d.ts +5 -4
  16. package/dist/effectEvent.js +29 -38
  17. package/dist/form.d.ts +2 -1
  18. package/dist/http.d.ts +2 -1
  19. package/dist/http.js +1 -1
  20. package/dist/index.d.ts +7 -4
  21. package/dist/index.js +6 -3
  22. package/dist/infinite-query.d.ts +42 -0
  23. package/dist/infinite-query.js +150 -0
  24. package/dist/jsx-runtime.d.ts +6 -0
  25. package/dist/jsx-runtime.js +14 -1
  26. package/dist/jsx.d.ts +2 -1
  27. package/dist/keyed-tasks.d.ts +30 -0
  28. package/dist/keyed-tasks.js +102 -0
  29. package/dist/lazy.d.ts +2 -1
  30. package/dist/lazy.js +20 -7
  31. package/dist/load.d.ts +1 -1
  32. package/dist/load.js +1 -1
  33. package/dist/mount.d.ts +13 -2
  34. package/dist/mount.js +82 -21
  35. package/dist/owner.d.ts +9 -5
  36. package/dist/owner.js +27 -9
  37. package/dist/pages.d.ts +16 -15
  38. package/dist/pages.js +8 -5
  39. package/dist/program.d.ts +14 -2
  40. package/dist/program.js +96 -36
  41. package/dist/query-internals.d.ts +5 -3
  42. package/dist/query.d.ts +7 -1
  43. package/dist/query.js +7 -3
  44. package/dist/resource.js +3 -1
  45. package/dist/runtime.d.ts +2 -1
  46. package/dist/runtime.js +3 -1
  47. package/dist/session.js +23 -7
  48. package/dist/settlement.d.ts +8 -0
  49. package/dist/settlement.js +32 -0
  50. package/dist/sharing.js +16 -13
  51. package/dist/snapshot.d.ts +1 -1
  52. package/dist/snapshot.js +31 -25
  53. package/dist/task.d.ts +1 -1
  54. package/dist/task.js +3 -1
  55. package/dist/tasks.d.ts +1 -1
  56. package/dist/tasks.js +3 -1
  57. package/dist/testing.d.ts +5 -4
  58. package/dist/testing.js +4 -2
  59. package/package.json +9 -1
package/dist/mount.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { reportError, reportSafely } from './errors.js';
2
- import { Effect, Fiber } from 'effect';
2
+ import * as Cause from 'effect/Cause';
3
+ import * as Deferred from 'effect/Deferred';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Fiber from 'effect/Fiber';
3
6
  import { defaultUiRuntime } from './runtime.js';
4
7
  export { Portal } from './dom.js';
5
8
  /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
@@ -26,34 +29,92 @@ export function domBinding(data, acquire, ...provided) {
26
29
  },
27
30
  };
28
31
  }
29
- export function startMount(element, mount, report = reportError) {
32
+ /** Allocate the lifetime before acquisition so reentrant publications can update or close it. */
33
+ export function prepareMount(element, mount, report = reportError) {
30
34
  let data = mount.data;
31
- const work = mount.acquire(element, () => data);
32
- const fiber = Effect.isEffect(work) ? Effect.runFork(work) : undefined;
35
+ let work;
36
+ let fiber;
37
+ let started = false;
33
38
  let disposed = false;
34
- fiber?.addObserver((exit) => {
35
- if (exit._tag === 'Failure' && !disposed)
36
- reportSafely(report, exit.cause);
37
- });
39
+ let released = false;
40
+ let changedDuringSetup = false;
41
+ const completed = Deferred.makeUnsafe();
42
+ const closed = Deferred.await(completed);
43
+ const finish = () => Deferred.doneUnsafe(completed, Effect.void);
44
+ const release = () => {
45
+ if (released || work === undefined || (Effect.isEffect(work) && !fiber))
46
+ return;
47
+ released = true;
48
+ if (fiber)
49
+ Effect.runFork(Fiber.interrupt(fiber));
50
+ else {
51
+ try {
52
+ if (typeof work === 'function')
53
+ work();
54
+ else if ('dispose' in work)
55
+ work.dispose();
56
+ }
57
+ finally {
58
+ finish();
59
+ }
60
+ }
61
+ };
62
+ const dispose = () => {
63
+ if (disposed)
64
+ return;
65
+ disposed = true;
66
+ if (!started)
67
+ finish();
68
+ else
69
+ release();
70
+ };
38
71
  return {
72
+ closed,
73
+ start() {
74
+ if (started || disposed)
75
+ return;
76
+ started = true;
77
+ try {
78
+ work = mount.acquire(element, () => data);
79
+ if (Effect.isEffect(work)) {
80
+ fiber = Effect.runFork(work);
81
+ fiber.addObserver((exit) => {
82
+ if (exit._tag === 'Failure' && (!disposed || !Cause.hasInterruptsOnly(exit.cause)))
83
+ reportSafely(report, exit.cause);
84
+ finish();
85
+ });
86
+ }
87
+ if (disposed)
88
+ release();
89
+ else if (changedDuringSetup && !fiber && typeof work === 'object' && 'update' in work)
90
+ work.update?.();
91
+ }
92
+ catch (error) {
93
+ dispose();
94
+ if (work === undefined)
95
+ finish();
96
+ throw error;
97
+ }
98
+ },
99
+ close: () => Effect.suspend(() => {
100
+ dispose();
101
+ return closed;
102
+ }),
39
103
  update(next) {
40
- if (next.identity !== mount.identity)
104
+ if (disposed || next.identity !== mount.identity)
41
105
  return false;
42
106
  data = next.data;
43
- if (!fiber && typeof work === 'object' && 'update' in work)
107
+ if (started && work === undefined)
108
+ changedDuringSetup = true;
109
+ else if (!fiber && typeof work === 'object' && work && 'update' in work)
44
110
  work.update?.();
45
111
  return true;
46
112
  },
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
- },
113
+ dispose,
58
114
  };
59
115
  }
116
+ export function startMount(element, mount, report = reportError) {
117
+ const lifetime = prepareMount(element, mount, report);
118
+ lifetime.start();
119
+ return lifetime;
120
+ }
package/dist/owner.d.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
- import { Effect } from 'effect';
2
+ import * as Effect from 'effect/Effect';
3
3
  import { type Program, type TaskPolicy, type CommandSlot } from './program.js';
4
4
  import { type ReportError } from './errors.js';
5
5
  import type { UiRuntime } from './runtime.js';
6
6
  export type { TaskPolicy } from './program.js';
7
+ interface OwnedResource {
8
+ dispose(): void;
9
+ close?(): Effect.Effect<void, unknown>;
10
+ }
7
11
  export interface DisposableOwner {
8
12
  readonly disposed: boolean;
9
- readonly own: <A extends {
10
- dispose(): void;
11
- }>(resource: A) => A;
13
+ readonly own: <A extends OwnedResource>(resource: A) => A;
12
14
  }
13
15
  export interface ModelOwner<Model extends object, R = never> extends DisposableOwner {
14
16
  readonly source: Program<Model, never>;
@@ -24,7 +26,9 @@ export interface ModelOwner<Model extends object, R = never> extends DisposableO
24
26
  readonly run: (slot: CommandSlot, effect: Effect.Effect<unknown, unknown, R>, policy: TaskPolicy) => void;
25
27
  readonly cancel: (slot: CommandSlot) => void;
26
28
  readonly isRunning: (slot: CommandSlot) => boolean;
27
- readonly awaitIdle: () => Promise<void>;
29
+ readonly awaitIdle: (slot?: CommandSlot) => Effect.Effect<void>;
30
+ /** Interrupt and join work before closing owned dependencies in reverse order. */
31
+ readonly close: () => Effect.Effect<void, AggregateError>;
28
32
  readonly dispose: () => void;
29
33
  }
30
34
  type Options = {
package/dist/owner.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { protectSnapshot } from './snapshot.js';
2
- import { Effect } from 'effect';
2
+ import * as Cause from 'effect/Cause';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Exit from 'effect/Exit';
3
5
  import { program, } from './program.js';
4
6
  import { patchModel } from './state.js';
5
7
  import { runAll, reportError } from './errors.js';
@@ -7,6 +9,7 @@ export function modelOwner(initial, options = {}) {
7
9
  let disposed = false;
8
10
  let staged;
9
11
  const cleanups = [];
12
+ let resourcesDisposed = false;
10
13
  const source = program({
11
14
  initial,
12
15
  ...options,
@@ -56,10 +59,29 @@ export function modelOwner(initial, options = {}) {
56
59
  return;
57
60
  disposed = true;
58
61
  source.dispose();
59
- runAll(cleanups.splice(0).reverse(), options.onDefect ?? reportError);
62
+ resourcesDisposed = true;
63
+ runAll([...cleanups].reverse().map((resource) => () => resource.dispose()), options.onDefect ?? reportError);
60
64
  };
65
+ const closing = Effect.runSync(Effect.cached(Effect.uninterruptible(Effect.gen(function* () {
66
+ disposed = true;
67
+ const resources = cleanups.splice(0).reverse();
68
+ yield* source.close();
69
+ const errors = [];
70
+ for (const resource of resources) {
71
+ const exit = yield* Effect.exit(Effect.suspend(() => resource.close
72
+ ? resource.close()
73
+ : resourcesDisposed
74
+ ? Effect.void
75
+ : Effect.sync(() => resource.dispose())));
76
+ if (Exit.isFailure(exit))
77
+ errors.push(Cause.squash(exit.cause));
78
+ }
79
+ if (errors.length)
80
+ return yield* Effect.fail(new AggregateError(errors, 'Owner cleanup failed.'));
81
+ }))));
82
+ const close = () => closing;
61
83
  return {
62
- source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose },
84
+ source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose, close },
63
85
  read,
64
86
  patch,
65
87
  fields: (...keys) => {
@@ -87,11 +109,6 @@ export function modelOwner(initial, options = {}) {
87
109
  staged = batch;
88
110
  try {
89
111
  const result = work();
90
- if (result &&
91
- (typeof result === 'object' || typeof result === 'function') &&
92
- 'then' in result &&
93
- typeof result.then === 'function')
94
- throw new TypeError('Model transactions must be synchronous. Run async work as an owned Effect.');
95
112
  staged = parent;
96
113
  if (!disposed) {
97
114
  if (parent) {
@@ -114,11 +131,12 @@ export function modelOwner(initial, options = {}) {
114
131
  get disposed() {
115
132
  return disposed;
116
133
  },
134
+ close,
117
135
  own(resource) {
118
136
  if (disposed)
119
137
  resource.dispose();
120
138
  else
121
- cleanups.push(() => resource.dispose());
139
+ cleanups.push(resource);
122
140
  return resource;
123
141
  },
124
142
  dispose,
package/dist/pages.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { Snapshot } from './snapshot.js';
2
- import { Cause } from 'effect';
2
+ import * as Cause from 'effect/Cause';
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
@@ -1,16 +1,19 @@
1
1
  import { commandSlot } from './program.js';
2
2
  import { shareData } from './sharing.js';
3
- import { Cause, Option } from 'effect';
3
+ import * as Cause from 'effect/Cause';
4
+ import * as Option from 'effect/Option';
4
5
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
5
6
  import { effectCommand, program, } from './program.js';
7
+ import { defaultUiRuntime } from './runtime.js';
6
8
  import { available } from './resource.js';
7
- const commandPage = commandSlot('page');
8
9
  /**
9
10
  * Pagination owns the `page` command slot. Key changes clear results and replace the request;
10
11
  * same-key refreshes keep the last success, and retries repeat the failed cursor.
11
12
  * Feed request inputs through receive; pass { refresh: true } for explicit same-key submissions.
12
13
  */
13
- export function pages(definition) {
14
+ export function pages(definition, ...provided) {
15
+ const runtime = provided[0] ?? defaultUiRuntime;
16
+ const commandPage = commandSlot('page');
14
17
  const request = (model, append) => {
15
18
  const previous = available(model.result);
16
19
  if (append && (model.result.waiting || !previous || previous.next === undefined))
@@ -21,11 +24,11 @@ export function pages(definition) {
21
24
  return {
22
25
  model: { ...model, append, result: AsyncResult.waiting(model.result) },
23
26
  commands: [
24
- effectCommand(commandPage, () => definition.load(model.props, cursor), {
27
+ runtime.command(effectCommand(commandPage, () => definition.load(model.props, cursor), {
25
28
  policy: 'replace',
26
29
  onSuccess: (page) => ({ type: 'Loaded', page, append }),
27
30
  onFailure: (cause) => ({ type: 'Failed', cause }),
28
- }),
31
+ })),
29
32
  ],
30
33
  };
31
34
  };
package/dist/program.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
- import { Cause, Effect, Stream } from 'effect';
2
+ import * as Cause from 'effect/Cause';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Stream from 'effect/Stream';
3
5
  export type Send<Message> = (message: Message) => void;
4
6
  /** queue retains FIFO requests; latest-queued retains only the newest pending request. */
5
7
  export type TaskPolicy = 'replace' | 'drop' | 'parallel' | 'queue' | 'latest-queued';
@@ -13,6 +15,8 @@ export declare const commandSlot: (name: string) => CommandSlot;
13
15
  export type Command<Message, R = never> = {
14
16
  readonly slot: CommandSlot;
15
17
  readonly policy: TaskPolicy;
18
+ /** Notification for work that never started. */
19
+ readonly onDiscard?: (reason: 'Cancelled' | 'Superseded' | 'Dropped') => void;
16
20
  } & ({
17
21
  readonly effect: Effect.Effect<Message, never, R>;
18
22
  readonly stream?: never;
@@ -40,15 +44,23 @@ export interface Transition<Model, Message, R = never> {
40
44
  readonly commands?: readonly Command<Message, R>[];
41
45
  readonly cancel?: readonly CommandSlot[];
42
46
  }
47
+ export declare function mapTransition<Model, Message, Parent, ParentMessage, R = never>(transition: Transition<Model, Message, R>, maps: {
48
+ readonly model: (model: Snapshot<Model>) => Parent | Snapshot<Parent>;
49
+ readonly message: (message: Message) => ParentMessage;
50
+ }): Transition<Parent, ParentMessage, R>;
43
51
  export interface Program<Model, Message> {
44
52
  readonly model: () => Snapshot<Model>;
45
53
  readonly send: Send<Message>;
46
54
  readonly subscribe: (listener: (model: Snapshot<Model>) => void) => () => void;
47
55
  readonly dispose: () => void;
56
+ readonly close?: () => Effect.Effect<void, unknown>;
48
57
  }
49
58
  export interface RunningProgram<Model, Message> extends Program<Model, Message> {
59
+ /** Wait for interrupted work and its finalizers as well as admitted work. */
60
+ readonly awaitStopped: () => Effect.Effect<void>;
61
+ readonly close: () => Effect.Effect<void>;
50
62
  readonly activeSlots: () => readonly CommandSlot[];
51
- readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
63
+ readonly awaitIdle: (slot?: CommandSlot) => Effect.Effect<void>;
52
64
  }
53
65
  export declare function program<Model, Message>(options: {
54
66
  initial: Model | Snapshot<Model>;
package/dist/program.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { protectSnapshot } from './snapshot.js';
2
2
  import { reportError, reportSafely } from './errors.js';
3
3
  import { traceProgram, nextProgramId, hasProgramObservers } from './diagnostics.js';
4
- import { Cause, Effect, Fiber, Option, Stream } from 'effect';
4
+ import * as Cause from 'effect/Cause';
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Fiber from 'effect/Fiber';
7
+ import * as Option from 'effect/Option';
8
+ import * as Stream from 'effect/Stream';
5
9
  export const commandSlot = (name) => Symbol(name);
6
- const taskPolicies = ['replace', 'drop', 'parallel', 'queue', 'latest-queued'];
7
10
  /** A lazy request settles successes, typed failures and defects through one message contract. */
8
11
  export function effectCommand(slot, load, handlers) {
9
12
  return {
@@ -23,6 +26,15 @@ export function mapCommand(command, map) {
23
26
  ? { ...command, stream: command.stream.pipe(Stream.map(map)) }
24
27
  : { ...command, effect: command.effect.pipe(Effect.map(map)) };
25
28
  }
29
+ export function mapTransition(transition, maps) {
30
+ return {
31
+ model: maps.model(transition.model),
32
+ ...(transition.cancel ? { cancel: transition.cancel } : {}),
33
+ ...(transition.commands
34
+ ? { commands: transition.commands.map((command) => mapCommand(command, maps.message)) }
35
+ : {}),
36
+ };
37
+ }
26
38
  export function program(options) {
27
39
  const id = nextProgramId();
28
40
  const trace = (kind, slot, message) => hasProgramObservers() &&
@@ -40,6 +52,7 @@ export function program(options) {
40
52
  });
41
53
  const waiters = new Set();
42
54
  const notify = () => {
55
+ notifyStopped();
43
56
  for (const waiter of waiters) {
44
57
  if (disposed ||
45
58
  (!draining && (waiter.slot ? !running.has(waiter.slot) : running.size === 0))) {
@@ -53,11 +66,40 @@ export function program(options) {
53
66
  let current = protectSnapshot(options.initial);
54
67
  const listeners = new Set();
55
68
  const running = new Map();
69
+ const live = new Set();
70
+ const stopped = new Set();
71
+ const notifyStopped = () => {
72
+ if (live.size || running.size || draining)
73
+ return;
74
+ const completed = [...stopped];
75
+ stopped.clear();
76
+ for (const done of completed)
77
+ done();
78
+ };
79
+ const awaitStopped = () => Effect.callback((resume) => {
80
+ if (live.size === 0 && running.size === 0 && !draining)
81
+ return resume(Effect.void);
82
+ const done = () => resume(Effect.void);
83
+ stopped.add(done);
84
+ return Effect.sync(() => {
85
+ stopped.delete(done);
86
+ });
87
+ });
56
88
  let disposed = false;
57
89
  let draining = false;
58
90
  const queue = [];
59
91
  const deferred = [];
60
- const cancel = (slot) => {
92
+ const discard = (command, reason) => {
93
+ if (command.onDiscard) {
94
+ try {
95
+ command.onDiscard(reason);
96
+ }
97
+ catch (error) {
98
+ reportSafely(options.onDefect ?? reportError, error);
99
+ }
100
+ }
101
+ };
102
+ const cancel = (slot, reason = 'Cancelled') => {
61
103
  // A synchronous Effect can enqueue completion behind a reset/replacement message.
62
104
  // Its fiber is already done, but cancellation still owns that unpublished completion.
63
105
  for (let index = queue.length - 1; index >= 0; index--)
@@ -67,10 +109,15 @@ export function program(options) {
67
109
  running.delete(slot);
68
110
  if (previous) {
69
111
  trace('cancel', slot);
70
- previous.pending.length = 0;
71
- for (const task of previous.active)
112
+ const pending = previous.pending.splice(0);
113
+ for (const command of pending)
114
+ discard(command, reason);
115
+ for (const task of previous.active) {
72
116
  if (task.fiber)
73
117
  Effect.runFork(Fiber.interrupt(task.fiber));
118
+ else if (task.command && !live.has(task))
119
+ discard(task.command, reason);
120
+ }
74
121
  previous.active.clear();
75
122
  }
76
123
  };
@@ -104,15 +151,17 @@ export function program(options) {
104
151
  : command.action
105
152
  ? command.action.pipe(Effect.as(Option.none()))
106
153
  : command.effect.pipe(Effect.map(Option.some));
154
+ live.add(task);
107
155
  const fiber = Effect.runFork(effect);
108
156
  task.fiber = fiber;
109
- if (!valid()) {
110
- Effect.runFork(Fiber.interrupt(fiber));
111
- return;
112
- }
113
157
  fiber.addObserver((exit) => {
114
- if (!valid())
158
+ live.delete(task);
159
+ if (!valid()) {
160
+ if (exit._tag === 'Failure' && !Cause.hasInterruptsOnly(exit.cause))
161
+ reportSafely(options.onDefect ?? reportError, exit.cause);
162
+ notifyStopped();
115
163
  return;
164
+ }
116
165
  group.active.delete(task);
117
166
  if (!group.active.size && !group.pending.length)
118
167
  running.delete(command.slot);
@@ -133,6 +182,8 @@ export function program(options) {
133
182
  }
134
183
  notify();
135
184
  });
185
+ if (!valid())
186
+ Effect.runFork(Fiber.interrupt(fiber));
136
187
  };
137
188
  const advance = (slot, group) => {
138
189
  if (disposed || running.get(slot) !== group || group.active.size)
@@ -140,7 +191,7 @@ export function program(options) {
140
191
  const next = group.pending.shift();
141
192
  if (!next)
142
193
  return;
143
- const task = {};
194
+ const task = { command: next };
144
195
  group.active.add(task);
145
196
  launch(next, group, task);
146
197
  };
@@ -160,10 +211,6 @@ export function program(options) {
160
211
  }
161
212
  const { message } = queue.shift();
162
213
  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
- }
167
214
  trace('update', undefined, message);
168
215
  for (const slot of transition.cancel ?? [])
169
216
  cancel(slot);
@@ -181,15 +228,18 @@ export function program(options) {
181
228
  break;
182
229
  const policy = command.policy;
183
230
  let group = running.get(command.slot);
184
- if (policy === 'drop' && group)
231
+ if (policy === 'drop' && group) {
232
+ discard(command, 'Dropped');
185
233
  continue;
234
+ }
186
235
  if (policy === 'replace') {
187
- cancel(command.slot);
236
+ cancel(command.slot, 'Superseded');
188
237
  group = undefined;
189
238
  }
190
239
  if (group && (policy === 'queue' || policy === 'latest-queued')) {
191
240
  if (policy === 'latest-queued')
192
- group.pending.length = 0;
241
+ for (const pending of group.pending.splice(0))
242
+ discard(pending, 'Superseded');
193
243
  group.pending.push(command);
194
244
  continue;
195
245
  }
@@ -197,7 +247,7 @@ export function program(options) {
197
247
  group = { active: new Set(), pending: [] };
198
248
  running.set(command.slot, group);
199
249
  }
200
- const task = {};
250
+ const task = { command };
201
251
  group.active.add(task);
202
252
  const admitted = group;
203
253
  starts.push(() => launch(command, admitted, task));
@@ -221,14 +271,35 @@ export function program(options) {
221
271
  }
222
272
  };
223
273
  const send = (message) => enqueue(message);
274
+ const dispose = () => {
275
+ if (disposed)
276
+ return;
277
+ disposed = true;
278
+ queue.length = 0;
279
+ deferred.length = 0;
280
+ for (const slot of running.keys())
281
+ cancel(slot);
282
+ listeners.clear();
283
+ trace('dispose');
284
+ notify();
285
+ };
224
286
  return {
287
+ awaitStopped,
288
+ close: () => Effect.suspend(() => {
289
+ dispose();
290
+ return awaitStopped();
291
+ }),
225
292
  model: () => current,
226
293
  activeSlots: () => [...running.keys()],
227
- awaitIdle: (slot) => disposed || (!draining && (slot ? !running.has(slot) : running.size === 0))
228
- ? Promise.resolve()
229
- : new Promise((done) => {
230
- waiters.add({ slot, done });
231
- }),
294
+ awaitIdle: (slot) => Effect.callback((resume) => {
295
+ if (disposed || (!draining && (slot ? !running.has(slot) : running.size === 0)))
296
+ return resume(Effect.void);
297
+ const waiter = { slot, done: () => resume(Effect.void) };
298
+ waiters.add(waiter);
299
+ return Effect.sync(() => {
300
+ waiters.delete(waiter);
301
+ });
302
+ }),
232
303
  send,
233
304
  subscribe(listener) {
234
305
  if (disposed)
@@ -246,17 +317,6 @@ export function program(options) {
246
317
  listeners.delete(receive);
247
318
  };
248
319
  },
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
- },
320
+ dispose,
261
321
  };
262
322
  }
@@ -1,9 +1,11 @@
1
- import type { Effect } from 'effect';
1
+ import type * as Effect from 'effect/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
@@ -1,4 +1,4 @@
1
- import type { Effect } from 'effect';
1
+ import type * as Effect from 'effect/Effect';
2
2
  import { type QueryDefinition } from './query-internals.js';
3
3
  /** Canonical data identity: plain objects, dense arrays, and finite scalar values. */
4
4
  export type QueryKey = string | number | boolean | null | undefined | readonly QueryKey[] | {
@@ -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
@@ -54,12 +54,16 @@ export function encodeQueryKey(key) {
54
54
  return encode(key);
55
55
  }
56
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.');
59
57
  const staleTime = definition.staleTime ?? Infinity;
60
58
  if (Number.isNaN(staleTime) || staleTime < 0)
61
59
  throw new RangeError('Query staleTime must be nonnegative.');
62
60
  const result = Object.freeze({ name: definition.name });
63
- registerQuery(result, Object.freeze({ ...definition, staleTime }));
61
+ registerQuery(result, Object.freeze({
62
+ ...definition,
63
+ ...(definition.groups ? { groups: Object.freeze([...definition.groups]) } : {}),
64
+ staleTime,
65
+ }));
64
66
  return result;
65
67
  }
68
+ /** Invalidation identity; equal diagnostic names do not share membership. */
69
+ export const queryGroup = (name) => Symbol(name);