effectweb 0.3.1 → 0.5.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 (66) hide show
  1. package/dist/AsyncContent.d.ts +1 -1
  2. package/dist/AsyncContent.js +18 -35
  3. package/dist/cache-internals.d.ts +3 -2
  4. package/dist/cache.d.ts +15 -12
  5. package/dist/cache.js +51 -10
  6. package/dist/collection.d.ts +1 -1
  7. package/dist/collection.js +1 -3
  8. package/dist/component.d.ts +1 -1
  9. package/dist/component.js +9 -4
  10. package/dist/dom.d.ts +64 -31
  11. package/dist/dom.js +237 -121
  12. package/dist/effectEvent.d.ts +10 -7
  13. package/dist/effectEvent.js +50 -46
  14. package/dist/form.d.ts +2 -1
  15. package/dist/http.d.ts +2 -1
  16. package/dist/http.js +1 -1
  17. package/dist/index.d.ts +10 -8
  18. package/dist/index.js +10 -8
  19. package/dist/infinite-query.d.ts +12 -8
  20. package/dist/infinite-query.js +21 -13
  21. package/dist/jsx-runtime.d.ts +6 -0
  22. package/dist/jsx-runtime.js +14 -1
  23. package/dist/jsx.d.ts +4 -1
  24. package/dist/keyed-tasks.d.ts +8 -6
  25. package/dist/keyed-tasks.js +29 -24
  26. package/dist/lazy.d.ts +4 -2
  27. package/dist/lazy.js +16 -10
  28. package/dist/load.d.ts +1 -1
  29. package/dist/load.js +1 -1
  30. package/dist/mount.d.ts +23 -8
  31. package/dist/mount.js +116 -27
  32. package/dist/owner.d.ts +13 -8
  33. package/dist/owner.js +37 -41
  34. package/dist/pages.d.ts +1 -1
  35. package/dist/pages.js +2 -1
  36. package/dist/program.d.ts +20 -8
  37. package/dist/program.js +44 -22
  38. package/dist/query-internals.d.ts +5 -3
  39. package/dist/query.d.ts +17 -6
  40. package/dist/query.js +6 -3
  41. package/dist/render.d.ts +17 -0
  42. package/dist/render.js +48 -0
  43. package/dist/resource.d.ts +4 -3
  44. package/dist/resource.js +12 -6
  45. package/dist/runtime.d.ts +12 -1
  46. package/dist/runtime.js +27 -5
  47. package/dist/session.d.ts +3 -2
  48. package/dist/session.js +15 -5
  49. package/dist/settlement.d.ts +8 -1
  50. package/dist/settlement.js +20 -8
  51. package/dist/snapshot.d.ts +1 -1
  52. package/dist/snapshot.js +10 -12
  53. package/dist/source.d.ts +38 -0
  54. package/dist/source.js +218 -0
  55. package/dist/task.d.ts +1 -1
  56. package/dist/task.js +3 -1
  57. package/dist/tasks.d.ts +9 -6
  58. package/dist/tasks.js +7 -4
  59. package/dist/testing.d.ts +5 -4
  60. package/dist/testing.js +4 -2
  61. package/package.json +10 -3
  62. package/README.md +0 -101
  63. package/dist/intrinsic.d.ts +0 -2
  64. package/dist/intrinsic.js +0 -120
  65. package/dist/slotIdentity.d.ts +0 -1
  66. package/dist/slotIdentity.js +0 -2
package/dist/mount.d.ts CHANGED
@@ -1,24 +1,39 @@
1
1
  import type { Snapshot } from './snapshot.js';
2
2
  import { type ReportError } from './errors.js';
3
- import { Effect } from 'effect';
3
+ import * as Effect from 'effect/Effect';
4
+ import * as Scope from 'effect/Scope';
5
+ import * as Exit from 'effect/Exit';
4
6
  import { type UiRuntime } from './runtime.js';
5
7
  export { Portal, type PortalProps } from './dom.js';
6
8
  type Work<R = never> = (() => void) | {
7
9
  dispose: () => void;
8
10
  update?: () => void;
9
- } | Effect.Effect<never, unknown, R>;
11
+ } | Effect.Effect<unknown, unknown, R | Scope.Scope>;
10
12
  export interface DomMount<T extends Element = Element> {
11
13
  readonly identity: unknown;
12
14
  readonly data: unknown;
13
15
  readonly acquire: (element: T, input: () => unknown) => Work;
14
16
  }
15
17
  /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
16
- export declare function domMount<T extends Element, R = never>(start: (element: T) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
18
+ export declare function domMount<T extends Element, R = never>(start: (element: T) => Work<R>, ...provided: [Exclude<R, Scope.Scope>] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
17
19
  /** A stable DOM lifecycle with fresh immutable inputs, for focus, measurements and native events. */
18
- export declare function domBinding<T extends Element, A, R = never>(data: A | Snapshot<A>, acquire: (element: T, input: () => Snapshot<A>) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
19
- export declare function startMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError): {
20
- closed: Promise<void>;
21
- close(): Promise<void>;
20
+ export declare function domBinding<T extends Element, A, R = never>(data: A | Snapshot<A>, acquire: (element: T, input: () => Snapshot<A>) => Work<R>, ...provided: [Exclude<R, Scope.Scope>] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
21
+ /** Describe a DOM acquisition in Effect setup; services are captured once, resources belong to the element. */
22
+ export declare const makeDomMount: <T extends Element, R = never>(start: (element: T) => Work<R>) => Effect.Effect<DomMount<T>, never, R>;
23
+ /** Capture services once and bind changing inputs without replacing the acquisition lifetime. */
24
+ export declare const makeDomBinding: <T extends Element, A, R = never>(acquire: (element: T, input: () => Snapshot<A>) => Work<R>) => Effect.Effect<(data: A | Snapshot<A>) => DomMount<T>, never, R>;
25
+ /** Allocate the lifetime before acquisition so reentrant publications can update or close it. */
26
+ export declare function prepareMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError, runtime?: UiRuntime<never>): {
27
+ closed: Effect.Effect<void, never, never>;
28
+ start(): void;
29
+ close: () => Effect.Effect<void, never, never>;
22
30
  update(next: DomMount<T>): boolean;
23
- dispose: () => void;
31
+ dispose: (exit?: Exit.Exit<unknown, unknown>) => void;
32
+ };
33
+ export declare function startMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError, runtime?: UiRuntime<never>): {
34
+ closed: Effect.Effect<void, never, never>;
35
+ start(): void;
36
+ close: () => Effect.Effect<void, never, never>;
37
+ update(next: DomMount<T>): boolean;
38
+ dispose: (exit?: Exit.Exit<unknown, unknown>) => void;
24
39
  };
package/dist/mount.js CHANGED
@@ -1,16 +1,21 @@
1
1
  import { reportError, reportSafely } from './errors.js';
2
- import { Effect, Fiber } from 'effect';
3
- import { defaultUiRuntime } from './runtime.js';
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';
6
+ import * as Scope from 'effect/Scope';
7
+ import * as Exit from 'effect/Exit';
8
+ import { uiRuntime } from './runtime.js';
4
9
  export { Portal } from './dom.js';
5
10
  /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
6
11
  export function domMount(start, ...provided) {
7
- const runtime = provided[0] ?? defaultUiRuntime;
12
+ const runtime = provided[0];
8
13
  return {
9
14
  identity: start,
10
15
  data: undefined,
11
16
  acquire: (element) => {
12
17
  const work = start(element);
13
- return Effect.isEffect(work) ? runtime.provide(work) : work;
18
+ return Effect.isEffect(work) && runtime ? runtime.provideScoped(work) : work;
14
19
  },
15
20
  };
16
21
  }
@@ -21,31 +26,66 @@ export function domBinding(data, acquire, ...provided) {
21
26
  data,
22
27
  acquire: (element, input) => {
23
28
  const work = acquire(element, input);
24
- const runtime = provided[0] ?? defaultUiRuntime;
25
- return Effect.isEffect(work) ? runtime.provide(work) : work;
29
+ const runtime = provided[0];
30
+ return Effect.isEffect(work) && runtime ? runtime.provideScoped(work) : work;
26
31
  },
27
32
  };
28
33
  }
29
- export function startMount(element, mount, report = reportError) {
34
+ /** Describe a DOM acquisition in Effect setup; services are captured once, resources belong to the element. */
35
+ export const makeDomMount = (start) => Effect.map(Effect.context(), (context) => {
36
+ const runtime = uiRuntime(context);
37
+ return {
38
+ identity: start,
39
+ data: undefined,
40
+ acquire: (element) => {
41
+ const work = start(element);
42
+ return Effect.isEffect(work) ? runtime.provideScoped(work) : work;
43
+ },
44
+ };
45
+ });
46
+ /** Capture services once and bind changing inputs without replacing the acquisition lifetime. */
47
+ export const makeDomBinding = (acquire) => Effect.map(Effect.context(), (context) => {
48
+ const runtime = uiRuntime(context);
49
+ return (data) => ({
50
+ identity: acquire,
51
+ data,
52
+ acquire: (element, input) => {
53
+ const work = acquire(element, input);
54
+ return Effect.isEffect(work) ? runtime.provideScoped(work) : work;
55
+ },
56
+ });
57
+ });
58
+ /** Allocate the lifetime before acquisition so reentrant publications can update or close it. */
59
+ export function prepareMount(element, mount, report = reportError, runtime) {
30
60
  let data = mount.data;
31
- const work = mount.acquire(element, () => data);
32
- const fiber = Effect.isEffect(work) ? Effect.runFork(work) : undefined;
61
+ let work;
62
+ let fiber;
63
+ const resourceScope = Scope.makeUnsafe();
64
+ let started = false;
33
65
  let disposed = false;
34
- let finish;
35
- const closed = new Promise((resolve) => {
36
- finish = resolve;
37
- });
38
- fiber?.addObserver((exit) => {
39
- if (exit._tag === 'Failure' && !disposed)
40
- reportSafely(report, exit.cause);
41
- finish();
42
- });
43
- const dispose = () => {
44
- if (disposed)
66
+ let released = false;
67
+ let closingExit;
68
+ let changedDuringSetup = false;
69
+ const completed = Deferred.makeUnsafe();
70
+ const closed = Deferred.await(completed);
71
+ const finish = () => Deferred.doneUnsafe(completed, Effect.void);
72
+ const release = (exit = Exit.void) => {
73
+ closingExit ??= exit;
74
+ if (released || work === undefined || (Effect.isEffect(work) && !fiber))
45
75
  return;
46
- disposed = true;
47
- if (fiber)
48
- Effect.runFork(Fiber.interrupt(fiber));
76
+ released = true;
77
+ if (fiber) {
78
+ // Acquisition may finish successfully while its resources remain mounted.
79
+ // Join acquisition before releasing its scope, including async finalizers.
80
+ Effect.runFork(Effect.gen(function* () {
81
+ yield* Fiber.interrupt(fiber);
82
+ yield* Scope.close(resourceScope, closingExit);
83
+ })).addObserver((exit) => {
84
+ if (exit._tag === 'Failure')
85
+ reportSafely(report, exit.cause);
86
+ finish();
87
+ });
88
+ }
49
89
  else {
50
90
  try {
51
91
  if (typeof work === 'function')
@@ -58,20 +98,69 @@ export function startMount(element, mount, report = reportError) {
58
98
  }
59
99
  }
60
100
  };
101
+ const dispose = (exit = Exit.void) => {
102
+ if (disposed)
103
+ return;
104
+ disposed = true;
105
+ if (!started)
106
+ finish();
107
+ else
108
+ release(exit);
109
+ };
61
110
  return {
62
111
  closed,
63
- close() {
112
+ start() {
113
+ if (started || disposed)
114
+ return;
115
+ started = true;
116
+ try {
117
+ work = mount.acquire(element, () => data);
118
+ if (Effect.isEffect(work)) {
119
+ const acquisition = Scope.provide(work, resourceScope);
120
+ fiber = runtime
121
+ ? runtime.runFork(acquisition)
122
+ : Effect.runFork(Effect.scoped(acquisition));
123
+ fiber.addObserver((exit) => {
124
+ // User reporting may synchronously unmount; retain the acquisition failure first.
125
+ if (exit._tag === 'Failure')
126
+ closingExit ??= exit;
127
+ if (exit._tag === 'Failure' && (!disposed || !Cause.hasInterruptsOnly(exit.cause)))
128
+ reportSafely(report, exit.cause);
129
+ if (exit._tag === 'Failure')
130
+ release(exit);
131
+ });
132
+ }
133
+ if (disposed)
134
+ release();
135
+ else if (changedDuringSetup && !fiber && typeof work === 'object' && 'update' in work)
136
+ work.update?.();
137
+ }
138
+ catch (error) {
139
+ dispose(Exit.die(error));
140
+ if (work === undefined)
141
+ finish();
142
+ throw error;
143
+ }
144
+ },
145
+ close: () => Effect.suspend(() => {
64
146
  dispose();
65
147
  return closed;
66
- },
148
+ }),
67
149
  update(next) {
68
- if (next.identity !== mount.identity)
150
+ if (disposed || next.identity !== mount.identity)
69
151
  return false;
70
152
  data = next.data;
71
- if (!fiber && typeof work === 'object' && 'update' in work)
153
+ if (started && work === undefined)
154
+ changedDuringSetup = true;
155
+ else if (!fiber && typeof work === 'object' && work && 'update' in work)
72
156
  work.update?.();
73
157
  return true;
74
158
  },
75
159
  dispose,
76
160
  };
77
161
  }
162
+ export function startMount(element, mount, report = reportError, runtime) {
163
+ const lifetime = prepareMount(element, mount, report, runtime);
164
+ lifetime.start();
165
+ return lifetime;
166
+ }
package/dist/owner.d.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
- import { Effect } from 'effect';
2
+ import * as Effect from 'effect/Effect';
3
+ import type * as Scope from 'effect/Scope';
3
4
  import { type Program, type TaskPolicy, type CommandSlot } from './program.js';
4
5
  import { type ReportError } from './errors.js';
5
- import type { UiRuntime } from './runtime.js';
6
+ import { type UiRuntime } from './runtime.js';
6
7
  export type { TaskPolicy } from './program.js';
8
+ interface OwnedResource {
9
+ dispose(): void;
10
+ close?(): Effect.Effect<void, unknown>;
11
+ }
7
12
  export interface DisposableOwner {
8
13
  readonly disposed: boolean;
9
- readonly own: <A extends {
10
- dispose(): void;
11
- }>(resource: A) => A;
14
+ readonly own: <A extends OwnedResource>(resource: A) => A;
12
15
  }
13
16
  export interface ModelOwner<Model extends object, R = never> extends DisposableOwner {
14
17
  readonly source: Program<Model, never>;
@@ -21,12 +24,12 @@ export interface ModelOwner<Model extends object, R = never> extends DisposableO
21
24
  readonly edit: <K extends keyof Model>(key: K, change: (value: Snapshot<Model[K]>) => Model[K] | Snapshot<Model[K]>) => void;
22
25
  /** Synchronous transaction. Reads see staged changes; a throw discards changes and work. */
23
26
  readonly transaction: <A>(work: () => A & (A extends PromiseLike<unknown> ? never : unknown)) => A;
24
- readonly run: (slot: CommandSlot, effect: Effect.Effect<unknown, unknown, R>, policy: TaskPolicy) => void;
27
+ readonly run: (slot: CommandSlot, effect: Effect.Effect<unknown, unknown, R | Scope.Scope>, policy: TaskPolicy) => void;
25
28
  readonly cancel: (slot: CommandSlot) => void;
26
29
  readonly isRunning: (slot: CommandSlot) => boolean;
27
- readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
30
+ readonly awaitIdle: (slot?: CommandSlot) => Effect.Effect<void>;
28
31
  /** Interrupt and join work before closing owned dependencies in reverse order. */
29
- readonly close: () => Promise<void>;
32
+ readonly close: () => Effect.Effect<void, AggregateError>;
30
33
  readonly dispose: () => void;
31
34
  }
32
35
  type Options = {
@@ -37,3 +40,5 @@ export declare function modelOwner<Model extends object>(initial: Model, options
37
40
  export declare function modelOwner<Model extends object, R>(initial: Model, options: Options & {
38
41
  runtime: UiRuntime<R>;
39
42
  }): ModelOwner<Model, R>;
43
+ /** Construct an owner using the current services and release it with the current Effect scope. */
44
+ export declare const makeModelOwner: <Model extends object, R = never>(initial: Model, options?: Options) => Effect.Effect<ModelOwner<Model, R>, never, R | Scope.Scope>;
package/dist/owner.js CHANGED
@@ -1,16 +1,27 @@
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';
8
+ import { makeUiRuntime } from './runtime.js';
6
9
  export function modelOwner(initial, options = {}) {
7
10
  let disposed = false;
8
11
  let staged;
9
12
  const cleanups = [];
10
- let closing;
13
+ let resourcesDisposed = false;
11
14
  const source = program({
12
15
  initial,
13
- ...options,
16
+ ...(options.name ? { name: options.name } : {}),
17
+ ...(options.onDefect ? { onDefect: options.onDefect } : {}),
18
+ ...(options.runtime
19
+ ? {
20
+ runtime: {
21
+ runFork: (effect) => options.runtime.runFork(effect),
22
+ },
23
+ }
24
+ : {}),
14
25
  update(snapshot, operations) {
15
26
  let model = snapshot;
16
27
  const single = operations.length === 1 ? operations[0] : undefined;
@@ -57,42 +68,27 @@ export function modelOwner(initial, options = {}) {
57
68
  return;
58
69
  disposed = true;
59
70
  source.dispose();
60
- runAll(cleanups
61
- .splice(0)
62
- .reverse()
63
- .map((resource) => () => resource.dispose()), options.onDefect ?? reportError);
71
+ resourcesDisposed = true;
72
+ runAll([...cleanups].reverse().map((resource) => () => resource.dispose()), options.onDefect ?? reportError);
64
73
  };
65
- const close = () => {
66
- if (closing)
67
- return closing;
74
+ const closing = Effect.runSync(Effect.cached(Effect.uninterruptible(Effect.gen(function* () {
68
75
  disposed = true;
69
76
  const resources = cleanups.splice(0).reverse();
70
- let finish;
71
- let fail;
72
- closing = new Promise((resolve, reject) => {
73
- finish = resolve;
74
- fail = reject;
75
- });
76
- const closeResources = async () => {
77
- await source.close();
78
- const errors = [];
79
- for (const resource of resources) {
80
- try {
81
- if (resource.close)
82
- await resource.close();
83
- else
84
- resource.dispose();
85
- }
86
- catch (error) {
87
- errors.push(error);
88
- }
89
- }
90
- if (errors.length)
91
- throw new AggregateError(errors, 'Owner cleanup failed.');
92
- };
93
- closeResources().then(finish, fail);
94
- return closing;
95
- };
77
+ yield* source.close();
78
+ const errors = [];
79
+ for (const resource of resources) {
80
+ const exit = yield* Effect.exit(Effect.suspend(() => resource.close
81
+ ? resource.close()
82
+ : resourcesDisposed
83
+ ? Effect.void
84
+ : Effect.sync(() => resource.dispose())));
85
+ if (Exit.isFailure(exit))
86
+ errors.push(Cause.squash(exit.cause));
87
+ }
88
+ if (errors.length)
89
+ return yield* Effect.fail(new AggregateError(errors, 'Owner cleanup failed.'));
90
+ }))));
91
+ const close = () => closing;
96
92
  return {
97
93
  source: { model: source.model, send: source.send, subscribe: source.subscribe, dispose, close },
98
94
  read,
@@ -122,11 +118,6 @@ export function modelOwner(initial, options = {}) {
122
118
  staged = batch;
123
119
  try {
124
120
  const result = work();
125
- if (result &&
126
- (typeof result === 'object' || typeof result === 'function') &&
127
- 'then' in result &&
128
- typeof result.then === 'function')
129
- throw new TypeError('Model transactions must be synchronous. Run async work as an owned Effect.');
130
121
  staged = parent;
131
122
  if (!disposed) {
132
123
  if (parent) {
@@ -160,3 +151,8 @@ export function modelOwner(initial, options = {}) {
160
151
  dispose,
161
152
  };
162
153
  }
154
+ /** Construct an owner using the current services and release it with the current Effect scope. */
155
+ export const makeModelOwner = (initial, options = {}) => Effect.gen(function* () {
156
+ const runtime = yield* makeUiRuntime();
157
+ return yield* Effect.acquireRelease(Effect.sync(() => modelOwner(initial, { ...options, runtime })), (owner) => owner.close().pipe(Effect.orDie));
158
+ });
package/dist/pages.d.ts CHANGED
@@ -1,5 +1,5 @@
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';
package/dist/pages.js CHANGED
@@ -1,6 +1,7 @@
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';
6
7
  import { defaultUiRuntime } from './runtime.js';
package/dist/program.d.ts CHANGED
@@ -1,5 +1,9 @@
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';
5
+ import type * as Scope from 'effect/Scope';
6
+ import { type UiRuntime } from './runtime.js';
3
7
  export type Send<Message> = (message: Message) => void;
4
8
  /** queue retains FIFO requests; latest-queued retains only the newest pending request. */
5
9
  export type TaskPolicy = 'replace' | 'drop' | 'parallel' | 'queue' | 'latest-queued';
@@ -16,15 +20,15 @@ export type Command<Message, R = never> = {
16
20
  /** Notification for work that never started. */
17
21
  readonly onDiscard?: (reason: 'Cancelled' | 'Superseded' | 'Dropped') => void;
18
22
  } & ({
19
- readonly effect: Effect.Effect<Message, never, R>;
23
+ readonly effect: Effect.Effect<Message, never, R | Scope.Scope>;
20
24
  readonly stream?: never;
21
25
  readonly action?: never;
22
26
  } | {
23
- readonly stream: Stream.Stream<Message, never, R>;
27
+ readonly stream: Stream.Stream<Message, never, R | Scope.Scope>;
24
28
  readonly effect?: never;
25
29
  readonly action?: never;
26
30
  } | {
27
- readonly action: Effect.Effect<void, unknown, R>;
31
+ readonly action: Effect.Effect<void, unknown, R | Scope.Scope>;
28
32
  readonly effect?: never;
29
33
  readonly stream?: never;
30
34
  });
@@ -51,19 +55,27 @@ export interface Program<Model, Message> {
51
55
  readonly send: Send<Message>;
52
56
  readonly subscribe: (listener: (model: Snapshot<Model>) => void) => () => void;
53
57
  readonly dispose: () => void;
54
- readonly close?: () => Promise<void>;
58
+ readonly close?: () => Effect.Effect<void, unknown>;
55
59
  }
56
60
  export interface RunningProgram<Model, Message> extends Program<Model, Message> {
57
61
  /** Wait for interrupted work and its finalizers as well as admitted work. */
58
- readonly awaitStopped: () => Promise<void>;
59
- readonly close: () => Promise<void>;
62
+ readonly awaitStopped: () => Effect.Effect<void>;
63
+ readonly close: () => Effect.Effect<void>;
60
64
  readonly activeSlots: () => readonly CommandSlot[];
61
- readonly awaitIdle: (slot?: CommandSlot) => Promise<void>;
65
+ readonly awaitIdle: (slot?: CommandSlot) => Effect.Effect<void>;
62
66
  }
63
67
  export declare function program<Model, Message>(options: {
64
68
  initial: Model | Snapshot<Model>;
65
69
  name?: string;
66
70
  update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message>;
67
71
  onDefect?: (cause: unknown) => void;
72
+ runtime?: Pick<UiRuntime<never>, 'runFork'>;
68
73
  }): RunningProgram<Model, Message>;
74
+ /** Construct a program in the current Effect environment and own it in the current scope. */
75
+ export declare const makeProgram: <Model, Message, R = never>(options: {
76
+ initial: Model | Snapshot<Model>;
77
+ name?: string;
78
+ update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message, R>;
79
+ onDefect?: (cause: unknown) => void;
80
+ }) => Effect.Effect<RunningProgram<Model, Message>, never, R | Scope.Scope>;
69
81
  export {};
package/dist/program.js CHANGED
@@ -1,15 +1,20 @@
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';
9
+ import { makeUiRuntime } from './runtime.js';
5
10
  export const commandSlot = (name) => Symbol(name);
6
- const taskPolicies = ['replace', 'drop', 'parallel', 'queue', 'latest-queued'];
7
11
  /** A lazy request settles successes, typed failures and defects through one message contract. */
8
12
  export function effectCommand(slot, load, handlers) {
9
13
  return {
10
14
  slot,
11
15
  policy: handlers.policy,
12
- effect: Effect.suspend(load).pipe(Effect.matchCause(handlers)),
16
+ // Release the operation with its actual exit before translating it into a message.
17
+ effect: Effect.scoped(Effect.suspend(load)).pipe(Effect.matchCause(handlers)),
13
18
  };
14
19
  }
15
20
  /** Owned work with no model result. Failures use the program's error reporter. */
@@ -68,13 +73,20 @@ export function program(options) {
68
73
  const notifyStopped = () => {
69
74
  if (live.size || running.size || draining)
70
75
  return;
71
- for (const done of stopped)
72
- done();
76
+ const completed = [...stopped];
73
77
  stopped.clear();
78
+ for (const done of completed)
79
+ done();
74
80
  };
75
- const awaitStopped = () => live.size === 0 && running.size === 0 && !draining
76
- ? Promise.resolve()
77
- : new Promise((done) => stopped.add(done));
81
+ const awaitStopped = () => Effect.callback((resume) => {
82
+ if (live.size === 0 && running.size === 0 && !draining)
83
+ return resume(Effect.void);
84
+ const done = () => resume(Effect.void);
85
+ stopped.add(done);
86
+ return Effect.sync(() => {
87
+ stopped.delete(done);
88
+ });
89
+ });
78
90
  let disposed = false;
79
91
  let draining = false;
80
92
  const queue = [];
@@ -142,13 +154,18 @@ export function program(options) {
142
154
  ? command.action.pipe(Effect.as(Option.none()))
143
155
  : command.effect.pipe(Effect.map(Option.some));
144
156
  live.add(task);
145
- const fiber = Effect.runFork(effect);
157
+ const fiber = options.runtime
158
+ ? options.runtime.runFork(effect)
159
+ : Effect.runFork(Effect.scoped(effect));
146
160
  task.fiber = fiber;
147
161
  fiber.addObserver((exit) => {
148
162
  live.delete(task);
149
- notifyStopped();
150
- if (!valid())
163
+ if (!valid()) {
164
+ if (exit._tag === 'Failure' && !Cause.hasInterruptsOnly(exit.cause))
165
+ reportSafely(options.onDefect ?? reportError, exit.cause);
166
+ notifyStopped();
151
167
  return;
168
+ }
152
169
  group.active.delete(task);
153
170
  if (!group.active.size && !group.pending.length)
154
171
  running.delete(command.slot);
@@ -198,10 +215,6 @@ export function program(options) {
198
215
  }
199
216
  const { message } = queue.shift();
200
217
  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
- }
205
218
  trace('update', undefined, message);
206
219
  for (const slot of transition.cancel ?? [])
207
220
  cancel(slot);
@@ -276,17 +289,21 @@ export function program(options) {
276
289
  };
277
290
  return {
278
291
  awaitStopped,
279
- close: () => {
292
+ close: () => Effect.suspend(() => {
280
293
  dispose();
281
294
  return awaitStopped();
282
- },
295
+ }),
283
296
  model: () => current,
284
297
  activeSlots: () => [...running.keys()],
285
- awaitIdle: (slot) => disposed || (!draining && (slot ? !running.has(slot) : running.size === 0))
286
- ? Promise.resolve()
287
- : new Promise((done) => {
288
- waiters.add({ slot, done });
289
- }),
298
+ awaitIdle: (slot) => Effect.callback((resume) => {
299
+ if (disposed || (!draining && (slot ? !running.has(slot) : running.size === 0)))
300
+ return resume(Effect.void);
301
+ const waiter = { slot, done: () => resume(Effect.void) };
302
+ waiters.add(waiter);
303
+ return Effect.sync(() => {
304
+ waiters.delete(waiter);
305
+ });
306
+ }),
290
307
  send,
291
308
  subscribe(listener) {
292
309
  if (disposed)
@@ -307,3 +324,8 @@ export function program(options) {
307
324
  dispose,
308
325
  };
309
326
  }
327
+ /** Construct a program in the current Effect environment and own it in the current scope. */
328
+ export const makeProgram = (options) => Effect.gen(function* () {
329
+ const runtime = yield* makeUiRuntime();
330
+ return yield* Effect.acquireRelease(Effect.sync(() => runtime.program(options)), (source) => source.close());
331
+ });
@@ -1,13 +1,15 @@
1
- import type { Effect } from 'effect';
1
+ import type * as Effect from 'effect/Effect';
2
+ import type * as Scope from 'effect/Scope';
2
3
  import type { Snapshot } from './snapshot.js';
3
- import type { Query, QueryGroup } from './query.js';
4
+ import type { Query, QueryGroup, QueryKey } from './query.js';
4
5
  export interface QueryDefinition<Args, A, E, R> {
5
6
  readonly name: string;
6
- readonly load: (args: Snapshot<Args>, previous?: Snapshot<A>) => Effect.Effect<A, E, R>;
7
+ readonly load: (args: Snapshot<Args>, previous?: Snapshot<A>) => Effect.Effect<A, E, R | Scope.Scope>;
7
8
  readonly groups?: readonly QueryGroup[];
8
9
  readonly unused?: 'retain' | 'cancel';
9
10
  readonly staleTime: number;
10
11
  readonly share?: (previous: Snapshot<A>, next: A | Snapshot<A>) => A | Snapshot<A>;
12
+ readonly encode?: (args: Snapshot<Args>) => QueryKey;
11
13
  }
12
14
  export declare function registerQuery<Args, A, E, R>(query: Query<Args, A, E, R>, definition: QueryDefinition<Args, A, E, R>): void;
13
15
  export declare function queryDefinition<Args, A, E, R>(query: Query<Args, A, E, R>): QueryDefinition<Args, A, E, R>;