@spfn/core 0.3.0-beta.24 → 0.3.0-beta.26
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 +4 -0
- package/dist/contract/index.d.ts +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/index.js +30 -10
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +12 -1
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +3 -3
- package/dist/event/sse/index.js +19 -9
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/{router-ZDV1ISTo.d.ts → router-D5B0cpVN.d.ts} +39 -10
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +19 -9
- package/dist/server/index.js.map +1 -1
- package/dist/state/client.d.ts +144 -0
- package/dist/state/client.js +478 -0
- package/dist/state/client.js.map +1 -0
- package/dist/state/index.d.ts +395 -0
- package/dist/state/index.js +578 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/react.d.ts +34 -0
- package/dist/state/react.js +44 -0
- package/dist/state/react.js.map +1 -0
- package/dist/{types-C_g6gD-w.d.ts → types-04gu3uMj.d.ts} +8 -1
- package/dist/{types-cwMX0cyJ.d.ts → types-CqTsDETV.d.ts} +1 -1
- package/package.json +26 -2
- package/src/event/README.md +48 -10
- package/src/state/README.md +539 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { Type, Kind } from '@sinclair/typebox';
|
|
2
|
+
import { Value } from '@sinclair/typebox/value';
|
|
3
|
+
import { defineEvent } from '@spfn/core/event';
|
|
4
|
+
import { logger } from '@spfn/core/logger';
|
|
5
|
+
import { defineRouter, route } from '@spfn/core/route';
|
|
6
|
+
import { UnauthorizedError, ForbiddenError } from '@spfn/core/errors';
|
|
7
|
+
|
|
8
|
+
// src/state/state-builder.ts
|
|
9
|
+
var bindings = /* @__PURE__ */ new WeakMap();
|
|
10
|
+
function resultSchema(def) {
|
|
11
|
+
return Type.Object(
|
|
12
|
+
{
|
|
13
|
+
value: def.value,
|
|
14
|
+
...def.data ? { data: def.data } : {}
|
|
15
|
+
},
|
|
16
|
+
{ additionalProperties: false }
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
function bindState(def, path) {
|
|
20
|
+
bindings.set(def, { path, resultSchema: resultSchema(def) });
|
|
21
|
+
}
|
|
22
|
+
function stateBinding(def) {
|
|
23
|
+
return bindings.get(def);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/state/state-event.ts
|
|
27
|
+
var stateLogger = logger.child("@spfn/core:state");
|
|
28
|
+
function stateEventName(id) {
|
|
29
|
+
return `state.${id}.changed`;
|
|
30
|
+
}
|
|
31
|
+
function pickKey(keySchema, source) {
|
|
32
|
+
const key = {};
|
|
33
|
+
for (const field of Object.keys(keySchema.properties)) {
|
|
34
|
+
const value = source[field];
|
|
35
|
+
if (typeof value !== "string") {
|
|
36
|
+
return void 0;
|
|
37
|
+
}
|
|
38
|
+
key[field] = value;
|
|
39
|
+
}
|
|
40
|
+
return key;
|
|
41
|
+
}
|
|
42
|
+
function readContext(def, subject, principal, key) {
|
|
43
|
+
if (!def.key) {
|
|
44
|
+
return { subject, principal };
|
|
45
|
+
}
|
|
46
|
+
return def.shared ? { principal, key } : { subject, principal, key };
|
|
47
|
+
}
|
|
48
|
+
function permissionContext(def, subject, principal, key) {
|
|
49
|
+
return def.key ? { subject, principal, key } : { subject, principal };
|
|
50
|
+
}
|
|
51
|
+
function requireBinding(def, what) {
|
|
52
|
+
const binding = stateBinding(def);
|
|
53
|
+
if (!binding) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`State "${def.id}" is in no defineStateRouter, so ${what} reaches nothing. The router subscribes the change signal and checks every read, and every builder call returns a new builder \u2014 an earlier one is never routed. Group the state with defineStateRouter and export the builder instance you grouped.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return binding;
|
|
59
|
+
}
|
|
60
|
+
function assertResult(def, binding, result) {
|
|
61
|
+
const error = Value.Errors(binding.resultSchema, result).First();
|
|
62
|
+
if (error) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`State "${def.id}" read() returned a result its declaration does not allow: ${error.path || "(root)"} ${error.message}. Every response and frame is checked against .value() and .data(), so a client never receives a value the contract does not describe. Return { value${def.data ? ", data" : ""} } as declared.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function readSnapshot(def, ctx, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
69
|
+
const binding = requireBinding(def, "a read");
|
|
70
|
+
const result = await def.read(ctx);
|
|
71
|
+
assertResult(def, binding, result);
|
|
72
|
+
return { at, ...result };
|
|
73
|
+
}
|
|
74
|
+
function toFrame(def, snapshot, subject, key) {
|
|
75
|
+
const frame = structuredClone(snapshot);
|
|
76
|
+
if (def.key) {
|
|
77
|
+
frame.key = { ...key };
|
|
78
|
+
}
|
|
79
|
+
if (!(def.key && def.shared)) {
|
|
80
|
+
frame.subject = subject;
|
|
81
|
+
}
|
|
82
|
+
return frame;
|
|
83
|
+
}
|
|
84
|
+
async function readAndPublish(def, event, subject, key, principal) {
|
|
85
|
+
const snapshot = await readSnapshot(def, readContext(def, subject, principal, key));
|
|
86
|
+
await event.emit(toFrame(def, snapshot, subject, key));
|
|
87
|
+
return snapshot;
|
|
88
|
+
}
|
|
89
|
+
function framePayloadSchema(def) {
|
|
90
|
+
return Type.Object({
|
|
91
|
+
at: Type.String(),
|
|
92
|
+
value: def.value ?? Type.Unknown(),
|
|
93
|
+
...def.data ? { data: def.data } : {},
|
|
94
|
+
...def.key ? { key: def.key } : {},
|
|
95
|
+
...def.key && def.shared ? {} : { subject: Type.String() }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function subscribePredicate(def) {
|
|
99
|
+
return async ({ subject, principal, request }) => {
|
|
100
|
+
if (!def.key) {
|
|
101
|
+
return def.canRead ? def.canRead({ subject, principal }) : true;
|
|
102
|
+
}
|
|
103
|
+
const key = pickKey(def.key, request.query);
|
|
104
|
+
return key !== void 0 && def.canRead !== void 0 && def.canRead({ subject, principal, key });
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function framePredicate(def) {
|
|
108
|
+
const fields = def.key ? Object.keys(def.key.properties) : [];
|
|
109
|
+
return (payload, { subject, request }) => subject !== void 0 && fields.every((field) => payload.key?.[field] === request.query[field]) && (payload.subject === void 0 || payload.subject === subject);
|
|
110
|
+
}
|
|
111
|
+
function createStateEvent(def) {
|
|
112
|
+
const event = defineEvent(stateEventName(def.id), framePayloadSchema(def)).canSubscribe(subscribePredicate(def)).filter(framePredicate(def));
|
|
113
|
+
if (def.contract) {
|
|
114
|
+
const { since, deprecatedIn, removedIn } = def.contract;
|
|
115
|
+
event.contract({
|
|
116
|
+
since,
|
|
117
|
+
...deprecatedIn ? { deprecatedIn } : {},
|
|
118
|
+
...removedIn ? { removedIn } : {}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return event;
|
|
122
|
+
}
|
|
123
|
+
function changedPayloadSchema(def) {
|
|
124
|
+
return Type.Object({
|
|
125
|
+
...def.key?.properties,
|
|
126
|
+
...def.key && def.shared ? {} : { subject: Type.String() }
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function assertChangedPayload(def, schema, payload) {
|
|
130
|
+
const error = Value.Errors(schema, payload).First();
|
|
131
|
+
if (error) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${def.id}.changed.emit() refused its payload: ${error.path || "(root)"} ${error.message}. The module re-reads exactly the subject and key the payload names, so a missing field cannot be guessed. Pass ${def.key ? "every key field" : "the subject"}${def.key && !def.shared ? " and the subject" : ""}.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function createChangedSignal(def) {
|
|
138
|
+
const schema = changedPayloadSchema(def);
|
|
139
|
+
const event = defineEvent(`state.${def.id}.signal`, schema);
|
|
140
|
+
const emit = event.emit;
|
|
141
|
+
event.emit = (async (payload) => {
|
|
142
|
+
requireBinding(def, `${def.id}.changed.emit()`);
|
|
143
|
+
assertChangedPayload(def, schema, payload);
|
|
144
|
+
await emit(payload);
|
|
145
|
+
});
|
|
146
|
+
return event;
|
|
147
|
+
}
|
|
148
|
+
function signalTarget(def, payload) {
|
|
149
|
+
const subject = def.key && def.shared ? void 0 : payload.subject;
|
|
150
|
+
return { subject, key: def.key ? pickKey(def.key, payload) : void 0 };
|
|
151
|
+
}
|
|
152
|
+
async function onSignal(state, payload) {
|
|
153
|
+
const { subject, key } = signalTarget(state._definition, payload);
|
|
154
|
+
try {
|
|
155
|
+
await readAndPublish(state._definition, state._event, subject, key, void 0);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
stateLogger.warn(`State "${state.id}" could not be re-read on its change signal; no frame was sent`, {
|
|
158
|
+
state: state.id,
|
|
159
|
+
error: error instanceof Error ? error.message : String(error)
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function onChangedBy(state, event, schema, payload) {
|
|
164
|
+
const error = Value.Errors(schema, payload).First();
|
|
165
|
+
if (error) {
|
|
166
|
+
stateLogger.warn(`State "${state.id}" skipped an "${event.name}" payload that names no value to re-read`, {
|
|
167
|
+
state: state.id,
|
|
168
|
+
event: event.name,
|
|
169
|
+
error: `${error.path || "(root)"} ${error.message}`
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await onSignal(state, payload);
|
|
174
|
+
}
|
|
175
|
+
function subscribeChangeSignals(state) {
|
|
176
|
+
const schema = changedPayloadSchema(state._definition);
|
|
177
|
+
state.changed.subscribe((payload) => onSignal(state, payload));
|
|
178
|
+
state._definition.changedBy?.forEach((event) => event.subscribe((payload) => onChangedBy(state, event, schema, payload), { localOnly: true }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/state/state-builder.ts
|
|
182
|
+
var STATE_ID = /^[a-z][a-zA-Z0-9]*$/;
|
|
183
|
+
var RESERVED_KEY_FIELDS = ["events", "token"];
|
|
184
|
+
var StateBuilder = class _StateBuilder {
|
|
185
|
+
constructor(id, _definition) {
|
|
186
|
+
this.id = id;
|
|
187
|
+
this._definition = _definition;
|
|
188
|
+
}
|
|
189
|
+
changedSignal;
|
|
190
|
+
stateEvent;
|
|
191
|
+
/**
|
|
192
|
+
* Declare the key: one value per key (and per subject, unless `shared`).
|
|
193
|
+
* Fields are required strings: they travel as query parameters.
|
|
194
|
+
*/
|
|
195
|
+
key(schema, options = {}) {
|
|
196
|
+
this.assertOnce("key", !!this._definition.key);
|
|
197
|
+
assertKeySchema(this.id, schema);
|
|
198
|
+
return this.with({ key: schema, shared: options.shared === true });
|
|
199
|
+
}
|
|
200
|
+
/** Declare the value schema. Required. */
|
|
201
|
+
value(schema) {
|
|
202
|
+
this.assertOnce("value", !!this._definition.value);
|
|
203
|
+
return this.with({ value: schema });
|
|
204
|
+
}
|
|
205
|
+
/** Declare data attached to the value. Optional. */
|
|
206
|
+
data(schema) {
|
|
207
|
+
this.assertOnce("data", !!this._definition.data);
|
|
208
|
+
return this.with({ data: schema });
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Middleware for the two per-state routes, `get<Id>State` / `put<Id>State`.
|
|
212
|
+
* Not authorization: the snapshot and the SSE stream cannot run it, so a
|
|
213
|
+
* state with `.use()` must declare `.canRead()`. Calls add up.
|
|
214
|
+
*/
|
|
215
|
+
use(middlewares) {
|
|
216
|
+
return this.with({ middlewares: [...this._definition.middlewares, ...middlewares] });
|
|
217
|
+
}
|
|
218
|
+
/** Server-level named middlewares the two per-state routes skip. Calls add up; `'*'` wins. Needs `.canRead()`, as `.use()`. */
|
|
219
|
+
skip(middlewareNames) {
|
|
220
|
+
const current = this._definition.skipMiddlewares ?? [];
|
|
221
|
+
const skipMiddlewares = current === "*" || middlewareNames === "*" ? "*" : [...current, ...middlewareNames];
|
|
222
|
+
return this.with({ skipMiddlewares });
|
|
223
|
+
}
|
|
224
|
+
/** Publish the read route, the write route and the event in the contract. */
|
|
225
|
+
contract(contract) {
|
|
226
|
+
this.assertOnce("contract", !!this._definition.contract);
|
|
227
|
+
return this.with({ contract });
|
|
228
|
+
}
|
|
229
|
+
/** Who may read. Required on a keyed state; checked on HTTP and at SSE connect. */
|
|
230
|
+
canRead(predicate) {
|
|
231
|
+
this.assertOnce("canRead", !!this._definition.canRead);
|
|
232
|
+
return this.with({ canRead: predicate });
|
|
233
|
+
}
|
|
234
|
+
/** Who may write. Absent → `canRead` decides. */
|
|
235
|
+
canWrite(predicate) {
|
|
236
|
+
this.assertOnce("canWrite", !!this._definition.canWrite);
|
|
237
|
+
return this.with({ canWrite: predicate });
|
|
238
|
+
}
|
|
239
|
+
/** The only source of a value. Required. */
|
|
240
|
+
read(fn) {
|
|
241
|
+
this.assertOnce("read", !!this._definition.read);
|
|
242
|
+
return this.with({ read: fn });
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Derive `PUT <path>/<id>`. `fn` runs the command; the value the route
|
|
246
|
+
* answers and broadcasts is re-read afterwards, never taken from `input`.
|
|
247
|
+
*/
|
|
248
|
+
write(input, fn, options = {}) {
|
|
249
|
+
this.assertOnce("write", !!this._definition.write);
|
|
250
|
+
return this.with({ write: { input, fn, options } });
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Domain events whose payload names a changed value of this state, treated
|
|
254
|
+
* like `changed.emit` with that payload: keyless `subject`, keyed the key
|
|
255
|
+
* fields and `subject`, shared the key fields. The router subscribes them
|
|
256
|
+
* and refuses one whose payload schema lacks a field.
|
|
257
|
+
*/
|
|
258
|
+
changedBy(events) {
|
|
259
|
+
this.assertOnce("changedBy", !!this._definition.changedBy);
|
|
260
|
+
return this.with({ changedBy: [...events] });
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The change signal. Emit it after the change is committed; the module
|
|
264
|
+
* re-reads the value and sends the frame. Refused on a builder no router
|
|
265
|
+
* grouped — export the instance you passed to `defineStateRouter`.
|
|
266
|
+
*/
|
|
267
|
+
get changed() {
|
|
268
|
+
this.changedSignal ??= createChangedSignal(this._definition);
|
|
269
|
+
return this.changedSignal;
|
|
270
|
+
}
|
|
271
|
+
/** Internal: the SSE event `state.<id>.changed` (`defineStateRouter(...).events`). */
|
|
272
|
+
get _event() {
|
|
273
|
+
this.stateEvent ??= createStateEvent(this._definition);
|
|
274
|
+
return this.stateEvent;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Activate the state's event on `subject`'s open connections and send it
|
|
278
|
+
* the current value at once. On a keyed state only the connections that
|
|
279
|
+
* declared `key` are opened. Does not consult `canRead`.
|
|
280
|
+
*/
|
|
281
|
+
async grant(target) {
|
|
282
|
+
const { subject, key } = target;
|
|
283
|
+
requireBinding(this._definition, `${this.id}.grant()`);
|
|
284
|
+
this.assertGrantKey(key);
|
|
285
|
+
const declared = this.declaredKey("grant", key);
|
|
286
|
+
await this._event.grant(subject, declared && { query: declared });
|
|
287
|
+
await readAndPublish(this._definition, this._event, subject, declared, void 0);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Deactivate the state's event on `subject`'s open connections: on a keyed
|
|
291
|
+
* state those that declared `key`, or every one when no key is given.
|
|
292
|
+
*/
|
|
293
|
+
async revoke(target) {
|
|
294
|
+
const { subject, key } = target;
|
|
295
|
+
const declared = this.declaredKey("revoke", key);
|
|
296
|
+
await this._event.revoke(subject, declared && { query: declared });
|
|
297
|
+
}
|
|
298
|
+
/** A keyed grant opens one key: without it there is no key to open and no value to send. */
|
|
299
|
+
assertGrantKey(key) {
|
|
300
|
+
if (this._definition.key && !key) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`State "${this.id}" .grant() was called without a key. A keyed grant opens exactly one key \u2014 every connection of the subject on any key would otherwise be opened, including keys canRead refused. Pass { subject, key }.`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The declared key fields of `key`, checked against `.key()`: an empty or
|
|
308
|
+
* partial key would match every connection of the subject. A field the
|
|
309
|
+
* declaration does not name is dropped, never matched on — so the check
|
|
310
|
+
* runs on a fresh object of the declared fields, never `.key()` itself: a
|
|
311
|
+
* key declared `additionalProperties: false` would otherwise refuse it.
|
|
312
|
+
* The fresh object keeps `.key()`'s `required`, and a key that still picks
|
|
313
|
+
* to nothing is refused: `undefined` here would mean every connection.
|
|
314
|
+
*/
|
|
315
|
+
declaredKey(method, key) {
|
|
316
|
+
const schema = this._definition.key;
|
|
317
|
+
if (!schema || key === void 0) {
|
|
318
|
+
return void 0;
|
|
319
|
+
}
|
|
320
|
+
const error = Value.Errors({ ...Type.Object(schema.properties), required: schema.required }, key).First();
|
|
321
|
+
if (error) {
|
|
322
|
+
this.refuseKey(method, `${error.path || "(root)"} ${error.message}`);
|
|
323
|
+
}
|
|
324
|
+
return pickKey(schema, key) ?? this.refuseKey(method, "(root) a declared key field is missing or not a string");
|
|
325
|
+
}
|
|
326
|
+
refuseKey(method, reason) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`State "${this.id}" .${method}() refused its key: ${reason}. A keyed ${method} reaches exactly the connections on one key, so every key field must be named.`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
with(changes) {
|
|
332
|
+
return new _StateBuilder(this.id, { ...this._definition, ...changes });
|
|
333
|
+
}
|
|
334
|
+
assertOnce(method, declared) {
|
|
335
|
+
if (declared) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`State "${this.id}" already declares .${method}(). A state is declared once, so a second declaration would silently replace the first. Declare .${method}() once.`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
function assertKeySchema(id, schema) {
|
|
343
|
+
if (schema[Kind] !== "Object") {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`State "${id}" .key() takes an object schema, got ${String(schema[Kind])}. Each key field becomes a named query parameter and a field of the frame's key. Wrap the field: .key(Type.Object({ workspaceId: Type.String() })).`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const fields = Object.entries(schema.properties);
|
|
349
|
+
if (fields.length === 0) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`State "${id}" .key() declares no field. A key with no field names nothing to read by. Drop .key() for one value per subject.`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
fields.forEach(([field, fieldSchema]) => assertKeyField(id, schema, field, fieldSchema));
|
|
355
|
+
}
|
|
356
|
+
function assertKeyField(id, schema, field, fieldSchema) {
|
|
357
|
+
if (RESERVED_KEY_FIELDS.includes(field)) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`State "${id}" key field "${field}" is reserved. The SSE stream reads ${RESERVED_KEY_FIELDS.map((name) => `"${name}"`).join(" and ")} from the connect query that also carries the key, so the field would be read as both. Rename the field.`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (fieldSchema.type !== "string" || !(schema.required ?? []).includes(field)) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`State "${id}" key field "${field}" is not a required string. A key travels as a query parameter, which is always a string, and the SSE filter compares it with the connect query as one \u2014 a number would never match, and a missing field names no value. Declare it ${field}: Type.String().`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function defineState(id) {
|
|
369
|
+
if (!STATE_ID.test(id)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`defineState("${id}"): a state id must match ${STATE_ID}. It becomes part of the route names get<Id>State / put<Id>State and of the event name state.<id>.changed. Use a lowerCamel identifier, e.g. "myState".`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return new StateBuilder(id, { id, shared: false, middlewares: [] });
|
|
375
|
+
}
|
|
376
|
+
var defaultSubject = (c) => c.get("auth")?.userId ?? null;
|
|
377
|
+
function stateRouteName(method, id) {
|
|
378
|
+
return `${method}${id[0].toUpperCase()}${id.slice(1)}State`;
|
|
379
|
+
}
|
|
380
|
+
function requireSubject(c, resolveSubject) {
|
|
381
|
+
const subject = resolveSubject(c);
|
|
382
|
+
if (!subject) {
|
|
383
|
+
throw new UnauthorizedError({ message: "A state is read for a subject, and this request carries none" });
|
|
384
|
+
}
|
|
385
|
+
return subject;
|
|
386
|
+
}
|
|
387
|
+
async function assertPermitted(def, can, ctx) {
|
|
388
|
+
const predicate = can === "canWrite" ? def.canWrite ?? def.canRead : def.canRead;
|
|
389
|
+
if (predicate && !await predicate(ctx)) {
|
|
390
|
+
throw new ForbiddenError({ message: `${can === "canWrite" ? "Writing" : "Reading"} state "${def.id}" is not permitted` });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function responseSchema(def) {
|
|
394
|
+
return Type.Object({
|
|
395
|
+
at: Type.String(),
|
|
396
|
+
value: def.value,
|
|
397
|
+
...def.data ? { data: def.data } : {}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function routeContract(def, write) {
|
|
401
|
+
if (!def.contract) {
|
|
402
|
+
return void 0;
|
|
403
|
+
}
|
|
404
|
+
const own = write ? stripUndefined(def.write.options) : {};
|
|
405
|
+
return stripUndefined({ ...def.contract, ...own, response: responseSchema(def) });
|
|
406
|
+
}
|
|
407
|
+
function stripUndefined(value) {
|
|
408
|
+
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
|
|
409
|
+
}
|
|
410
|
+
function stateRoute(def, method, path, input) {
|
|
411
|
+
const contract = routeContract(def, method === "put");
|
|
412
|
+
const builder = route[method](path).input(input).use(def.middlewares);
|
|
413
|
+
const skipped = def.skipMiddlewares ? builder.skip(def.skipMiddlewares) : builder;
|
|
414
|
+
return contract ? skipped.contract(contract) : skipped;
|
|
415
|
+
}
|
|
416
|
+
function keyInput(def) {
|
|
417
|
+
return def.key ? { query: def.key } : {};
|
|
418
|
+
}
|
|
419
|
+
function readRoute(state, path, resolveSubject) {
|
|
420
|
+
const def = state._definition;
|
|
421
|
+
return stateRoute(def, "get", `${path}/${def.id}`, keyInput(def)).handler(async (c) => {
|
|
422
|
+
const subject = requireSubject(c.raw, resolveSubject);
|
|
423
|
+
const principal = c.raw.get("auth");
|
|
424
|
+
const key = def.key ? pickKey(def.key, (await c.data()).query) : void 0;
|
|
425
|
+
await assertPermitted(def, "canRead", permissionContext(def, subject, principal, key));
|
|
426
|
+
return readSnapshot(def, readContext(def, subject, principal, key));
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function writeRoute(state, path, resolveSubject) {
|
|
430
|
+
const def = state._definition;
|
|
431
|
+
const write = def.write;
|
|
432
|
+
return stateRoute(def, "put", `${path}/${def.id}`, { body: write.input, ...keyInput(def) }).handler(async (c) => {
|
|
433
|
+
const subject = requireSubject(c.raw, resolveSubject);
|
|
434
|
+
const principal = c.raw.get("auth");
|
|
435
|
+
const { body, query } = await c.data();
|
|
436
|
+
const key = def.key ? pickKey(def.key, query) : void 0;
|
|
437
|
+
await assertPermitted(def, "canWrite", permissionContext(def, subject, principal, key));
|
|
438
|
+
await write.fn({ ...readContext(def, subject, principal, key), input: body });
|
|
439
|
+
return readAndPublish(def, state._event, subject, key, principal);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
function snapshotQuery(states) {
|
|
443
|
+
const fields = {};
|
|
444
|
+
for (const state of states) {
|
|
445
|
+
for (const [field, schema] of Object.entries(state._definition.key?.properties ?? {})) {
|
|
446
|
+
fields[field] = Type.Optional(schema);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return Type.Object(fields);
|
|
450
|
+
}
|
|
451
|
+
async function snapshotEntry(def, subject, principal, query, at) {
|
|
452
|
+
const key = def.key ? pickKey(def.key, query) : void 0;
|
|
453
|
+
if (def.key && !key) {
|
|
454
|
+
return void 0;
|
|
455
|
+
}
|
|
456
|
+
if (def.canRead && !await def.canRead(permissionContext(def, subject, principal, key))) {
|
|
457
|
+
return void 0;
|
|
458
|
+
}
|
|
459
|
+
const { value, data } = await readSnapshot(def, readContext(def, subject, principal, key), at);
|
|
460
|
+
return def.data ? { value, data } : { value };
|
|
461
|
+
}
|
|
462
|
+
function stateManifest(states) {
|
|
463
|
+
return Object.fromEntries(states.map((state) => [state.id, {
|
|
464
|
+
key: Object.keys(state._definition.key?.properties ?? {}),
|
|
465
|
+
write: state._definition.write !== void 0
|
|
466
|
+
}]));
|
|
467
|
+
}
|
|
468
|
+
function snapshotRoute(states, manifest, path, resolveSubject) {
|
|
469
|
+
return route.get(path).input({ query: snapshotQuery(states) }).handler(async (c) => {
|
|
470
|
+
const subject = requireSubject(c.raw, resolveSubject);
|
|
471
|
+
const principal = c.raw.get("auth");
|
|
472
|
+
const { query } = await c.data();
|
|
473
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
474
|
+
const entries = await Promise.all(states.map((state) => snapshotEntry(state._definition, subject, principal, query, at)));
|
|
475
|
+
return {
|
|
476
|
+
at,
|
|
477
|
+
states: Object.fromEntries(states.map((state, index) => [state.id, entries[index]]).filter(([, entry]) => entry !== void 0)),
|
|
478
|
+
manifest
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/state/state-router.ts
|
|
484
|
+
function defineStateRouter(states, options = {}) {
|
|
485
|
+
const path = options.path ?? "/state";
|
|
486
|
+
const list = Object.values(states);
|
|
487
|
+
assertPath(path);
|
|
488
|
+
list.forEach(assertRoutable);
|
|
489
|
+
assertDistinctIds(list);
|
|
490
|
+
list.forEach((state) => assertNotRouted(state, path));
|
|
491
|
+
list.forEach((state) => bindState(state._definition, path));
|
|
492
|
+
list.forEach(subscribeChangeSignals);
|
|
493
|
+
return {
|
|
494
|
+
router: defineRouter(deriveRoutes(list, path, options.subject ?? defaultSubject)),
|
|
495
|
+
events: Object.fromEntries(list.map((state) => [stateEventName(state.id), state._event])),
|
|
496
|
+
states
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function deriveRoutes(states, path, resolveSubject) {
|
|
500
|
+
const routes = {};
|
|
501
|
+
for (const state of states) {
|
|
502
|
+
routes[stateRouteName("get", state.id)] = readRoute(state, path, resolveSubject);
|
|
503
|
+
if (state._definition.write) {
|
|
504
|
+
routes[stateRouteName("put", state.id)] = writeRoute(state, path, resolveSubject);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
routes.getStates = snapshotRoute(states, stateManifest(states), path, resolveSubject);
|
|
508
|
+
return routes;
|
|
509
|
+
}
|
|
510
|
+
function assertPath(path) {
|
|
511
|
+
if (!path.startsWith("/") || path.length > 1 && path.endsWith("/")) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
`defineStateRouter(..., { path: "${path}" }) is not an absolute path without a trailing slash. The routes are mounted at <path> and <path>/<id>. Write it as "/state".`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function assertRoutable(state) {
|
|
518
|
+
const def = state._definition;
|
|
519
|
+
const missing = !def.value ? ".value()" : !def.read ? ".read()" : void 0;
|
|
520
|
+
if (missing) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`defineStateRouter: state "${state.id}" declares no ${missing}. A state answers with the value its read returns, so both are required. Add ${missing} to defineState('${state.id}').`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (def.key && !def.canRead) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`defineStateRouter: keyed state "${state.id}" declares no .canRead(). The key comes from the request, so without it any subject could read any key. Add .canRead(({ subject, key }) => ...).`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
assertMiddlewareIsNotAuthorization(state);
|
|
531
|
+
def.changedBy?.forEach((event) => assertChangedBy(state, event));
|
|
532
|
+
if (def.write && !def.contract && def.write.options.since) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
`defineStateRouter: state "${state.id}" gives .write(..., { since: '${def.write.options.since}' }) but declares no .contract(), so there is nothing to fall back from: the write route would have a version but no auth profile, and the read route and the event would not be contracted at all. Add .contract({ since, ... }) to the state, or drop since from the write.`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function assertMiddlewareIsNotAuthorization(state) {
|
|
539
|
+
const def = state._definition;
|
|
540
|
+
if ((def.middlewares.length > 0 || def.skipMiddlewares) && !def.canRead) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`defineStateRouter: state "${state.id}" declares .use() or .skip() but no .canRead(). Middleware reaches only ${stateRouteName("get", state.id)} / ${stateRouteName("put", state.id)} \u2014 never the snapshot route or the SSE stream \u2014 so it cannot authorize. Authorization belongs in .canRead(), which is enforced on the read route, the snapshot and the stream. A state that needs middleware only for, say, rate limiting declares .canRead(() => true) to say so.`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function assertChangedBy(state, event) {
|
|
547
|
+
const payload = event.schema;
|
|
548
|
+
const signal = changedPayloadSchema(state._definition);
|
|
549
|
+
const missing = Object.keys(signal.properties).find((field) => !payload?.properties?.[field] || !(payload.required ?? []).includes(field));
|
|
550
|
+
if (missing) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`defineStateRouter: state "${state.id}" lists event "${event.name}" in .changedBy(), but its payload schema has no required field "${missing}". Its payload is read like ${state.id}.changed.emit(), which names ${Object.keys(signal.properties).join(", ")}. Add the field to the event, or emit ${state.id}.changed instead.`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function assertDistinctIds(states) {
|
|
557
|
+
const seen = /* @__PURE__ */ new Set();
|
|
558
|
+
for (const state of states) {
|
|
559
|
+
if (seen.has(state.id)) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
`defineStateRouter: two states have the id "${state.id}". Both would derive the routes ${stateRouteName("get", state.id)} and the event ${stateEventName(state.id)}, and one would shadow the other. Rename one.`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
seen.add(state.id);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function assertNotRouted(state, path) {
|
|
568
|
+
const other = stateBinding(state._definition)?.path;
|
|
569
|
+
if (other !== void 0) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
`defineStateRouter: state "${state.id}" is already in another state router (path ${other}, this one ${path}). Each router subscribes the state's change signal, so every change would be read and sent twice. Group each state in one router.`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export { StateBuilder, defineState, defineStateRouter };
|
|
577
|
+
//# sourceMappingURL=index.js.map
|
|
578
|
+
//# sourceMappingURL=index.js.map
|