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