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.
- package/README.md +35 -4
- package/dist/AsyncContent.d.ts +1 -1
- package/dist/AsyncContent.js +18 -35
- package/dist/boundary.d.ts +11 -0
- package/dist/boundary.js +66 -0
- package/dist/cache-internals.d.ts +4 -0
- package/dist/cache.d.ts +21 -5
- package/dist/cache.js +178 -25
- package/dist/collection.d.ts +1 -1
- package/dist/collection.js +1 -3
- package/dist/component.d.ts +3 -0
- package/dist/component.js +73 -10
- package/dist/dom.d.ts +35 -24
- package/dist/dom.js +177 -128
- package/dist/effectEvent.d.ts +5 -4
- package/dist/effectEvent.js +29 -38
- package/dist/form.d.ts +2 -1
- package/dist/http.d.ts +2 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/infinite-query.d.ts +42 -0
- package/dist/infinite-query.js +150 -0
- package/dist/jsx-runtime.d.ts +6 -0
- package/dist/jsx-runtime.js +14 -1
- package/dist/jsx.d.ts +2 -1
- package/dist/keyed-tasks.d.ts +30 -0
- package/dist/keyed-tasks.js +102 -0
- package/dist/lazy.d.ts +2 -1
- package/dist/lazy.js +20 -7
- package/dist/load.d.ts +1 -1
- package/dist/load.js +1 -1
- package/dist/mount.d.ts +13 -2
- package/dist/mount.js +82 -21
- package/dist/owner.d.ts +9 -5
- package/dist/owner.js +27 -9
- package/dist/pages.d.ts +16 -15
- package/dist/pages.js +8 -5
- package/dist/program.d.ts +14 -2
- package/dist/program.js +96 -36
- package/dist/query-internals.d.ts +5 -3
- package/dist/query.d.ts +7 -1
- package/dist/query.js +7 -3
- package/dist/resource.js +3 -1
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +3 -1
- package/dist/session.js +23 -7
- package/dist/settlement.d.ts +8 -0
- package/dist/settlement.js +32 -0
- package/dist/sharing.js +16 -13
- package/dist/snapshot.d.ts +1 -1
- package/dist/snapshot.js +31 -25
- package/dist/task.d.ts +1 -1
- package/dist/task.js +3 -1
- package/dist/tasks.d.ts +1 -1
- package/dist/tasks.js +3 -1
- package/dist/testing.d.ts +5 -4
- package/dist/testing.js +4 -2
- package/package.json +9 -1
package/dist/mount.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { reportError, reportSafely } from './errors.js';
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
35
|
+
let work;
|
|
36
|
+
let fiber;
|
|
37
|
+
let started = false;
|
|
33
38
|
let disposed = false;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 (
|
|
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
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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>,
|
|
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<
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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.
|
|
71
|
-
for (const
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
228
|
-
?
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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({
|
|
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);
|