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