@spfn/core 0.3.0-beta.25 → 0.3.0-beta.27

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.
@@ -21,6 +21,8 @@ interface StateShape {
21
21
  data: TSchema | undefined;
22
22
  input: TSchema | undefined;
23
23
  read: boolean;
24
+ /** Who the callbacks' `principal` is, from `.principal<P>()`; `unknown` until declared. */
25
+ principal: unknown;
24
26
  }
25
27
  /** The shape of `defineState(id)` before any call. */
26
28
  interface EmptyStateShape extends StateShape {
@@ -30,6 +32,7 @@ interface EmptyStateShape extends StateShape {
30
32
  data: undefined;
31
33
  input: undefined;
32
34
  read: false;
35
+ principal: unknown;
33
36
  }
34
37
  /** `T` with the fields of `U` replaced. */
35
38
  type WithShape<T extends StateShape, U extends Partial<StateShape>> = {
@@ -57,22 +60,38 @@ type StateResult<T extends StateShape> = T['data'] extends TSchema ? {
57
60
  type StateSnapshot<T extends StateShape> = Simplify<{
58
61
  at: string;
59
62
  } & StateResult<T>>;
60
- /** What `.canRead()` / `.canWrite()` decide on. */
63
+ /**
64
+ * What `.canRead()` decides on. `principal` may be `undefined`: at SSE connect
65
+ * it is what `resolvePrincipal` returned.
66
+ */
61
67
  type PermissionContext<T extends StateShape> = T['key'] extends TObject ? {
62
68
  subject: string;
63
- principal: unknown;
69
+ principal: T['principal'] | undefined;
64
70
  key: Static<T['key']>;
65
71
  } : {
66
72
  subject: string;
67
- principal: unknown;
73
+ principal: T['principal'] | undefined;
68
74
  };
69
- /** What `read()` receives. A shared state's value does not depend on the subject, so it gets none. */
75
+ /**
76
+ * What `.canWrite()` decides on. Only the write route asks it, and with
77
+ * `.principal()` declared that route refuses a request whose `auth` is
78
+ * `undefined` or `null` (401).
79
+ */
80
+ type WritePermissionContext<T extends StateShape> = Simplify<Omit<PermissionContext<T>, 'principal'> & {
81
+ principal: T['principal'];
82
+ }>;
83
+ /**
84
+ * What `read()` receives. A shared state's value does not depend on the
85
+ * subject, so it gets none. `principal` may be `undefined`: the change
86
+ * signal's re-read has no request.
87
+ */
70
88
  type ReadContext<T extends StateShape> = T['key'] extends TObject ? T['shared'] extends true ? {
71
- principal: unknown;
89
+ principal: T['principal'] | undefined;
72
90
  key: Static<T['key']>;
73
91
  } : PermissionContext<T> : PermissionContext<T>;
74
- /** What `write()` receives: the read context plus the validated input. */
75
- type WriteContext<T extends StateShape, I extends TSchema> = Simplify<ReadContext<T> & {
92
+ /** What `write()` receives: the read context, the write route's principal and the validated input. */
93
+ type WriteContext<T extends StateShape, I extends TSchema> = Simplify<Omit<ReadContext<T>, 'principal'> & {
94
+ principal: T['principal'];
76
95
  input: Static<I>;
77
96
  }>;
78
97
  /** The payload of `state.changed.emit()`: exactly what names the value to re-read. */
@@ -137,6 +156,8 @@ interface StateDefinition {
137
156
  id: string;
138
157
  key?: TObject;
139
158
  shared: boolean;
159
+ /** Whether `.principal()` was declared: the write route then refuses a request whose `auth` is `undefined` or `null`. */
160
+ principal: boolean;
140
161
  value?: TSchema;
141
162
  data?: TSchema;
142
163
  middlewares: (MiddlewareHandler | NamedMiddleware<string>)[];
@@ -204,6 +225,15 @@ declare class StateBuilder<TId extends string, T extends StateShape> {
204
225
  data<D extends TSchema>(schema: D): StateBuilder<TId, WithShape<T, {
205
226
  data: D;
206
227
  }>>;
228
+ /**
229
+ * Declare who the callbacks' `principal` is — what the app's auth
230
+ * middleware sets as `c.get('auth')`. Type-only, but for one rule: the write
231
+ * route refuses a request without a principal (401), so `.canWrite()` and
232
+ * `.write()` receive `P`; `.read()` and `.canRead()` receive `P | undefined`.
233
+ */
234
+ principal<P>(): StateBuilder<TId, WithShape<T, {
235
+ principal: P;
236
+ }>>;
207
237
  /**
208
238
  * Middleware for the two per-state routes, `get<Id>State` / `put<Id>State`.
209
239
  * Not authorization: the snapshot and the SSE stream cannot run it, so a
@@ -217,7 +247,7 @@ declare class StateBuilder<TId extends string, T extends StateShape> {
217
247
  /** Who may read. Required on a keyed state; checked on HTTP and at SSE connect. */
218
248
  canRead(predicate: (ctx: PermissionContext<T>) => boolean | Promise<boolean>): StateBuilder<TId, T>;
219
249
  /** Who may write. Absent → `canRead` decides. */
220
- canWrite(predicate: (ctx: PermissionContext<T>) => boolean | Promise<boolean>): StateBuilder<TId, T>;
250
+ canWrite(predicate: (ctx: WritePermissionContext<T>) => boolean | Promise<boolean>): StateBuilder<TId, T>;
221
251
  /** The only source of a value. Required. */
222
252
  read(fn: (ctx: ReadContext<T>) => StateResult<T> | Promise<StateResult<T>>): StateBuilder<TId, WithShape<T, {
223
253
  read: true;
@@ -294,6 +324,24 @@ declare function defineState<TId extends string>(id: TId): StateBuilder<TId, Emp
294
324
 
295
325
  /** Who a request is for. `null` answers 401. */
296
326
  type SubjectResolver = (c: Context) => string | null;
327
+ /**
328
+ * What the router gives the snapshot route `GET <path>` alone. A per-state
329
+ * route keeps its own state's `.use()` / `.skip()` / `.contract()`, and the
330
+ * SSE stream runs none of them.
331
+ */
332
+ interface SnapshotRouteOptions {
333
+ /** Middleware for the snapshot route only. Not authorization: the stream never runs it (see README). */
334
+ use?: (MiddlewareHandler | NamedMiddleware<string>)[];
335
+ /** Server-level named middlewares the snapshot route skips; `'*'` skips every one. */
336
+ skip?: string[] | '*';
337
+ /** Publishes the snapshot route in the contract; the response schema is derived. */
338
+ contract?: StateContract;
339
+ }
340
+ /** What the browser cannot read from a type: every state, in declaration order, its key fields and whether it has a write. */
341
+ type StatesManifest = Record<string, {
342
+ key: string[];
343
+ write: boolean;
344
+ }>;
297
345
 
298
346
  /**
299
347
  * State Router
@@ -303,7 +351,12 @@ type SubjectResolver = (c: Context) => string | null;
303
351
  * subscribes each state's change signal — once, here.
304
352
  */
305
353
 
306
- interface StateRouterOptions {
354
+ /**
355
+ * Where the router mounts, whom a request is for, and the snapshot route's own
356
+ * `use` / `skip` / `contract` (`SnapshotRouteOptions`) — those reach `GET <path>`
357
+ * only, never a per-state route or the SSE stream.
358
+ */
359
+ interface StateRouterOptions extends SnapshotRouteOptions {
307
360
  /**
308
361
  * Where the routes are mounted: `GET <path>`, `GET|PUT <path>/<id>`.
309
362
  * @default '/state'
@@ -328,12 +381,22 @@ type WriteInput<S extends RoutableState> = KeyQuery<S> & {
328
381
  type WriteRoutes<S extends Record<string, RoutableState>> = {
329
382
  [K in keyof S as Shape<S[K]>['input'] extends TSchema ? `put${Capitalize<S[K]['id']>}State` : never]: RouteDef<WriteInput<S[K]>, {}, StateSnapshot<Shape<S[K]>>>;
330
383
  };
331
- /** What `GET <path>` answers: every state the subject may read and whose key the query carries. */
384
+ /**
385
+ * What `GET <path>` answers: every state the subject may read and whose key
386
+ * the query carries, and the manifest of every state — its key fields and
387
+ * whether it has a write — in declaration order.
388
+ */
332
389
  type StatesSnapshot<S extends Record<string, RoutableState>> = {
333
390
  at: string;
334
391
  states: {
335
392
  [K in keyof S as S[K]['id']]?: StateResult<Shape<S[K]>>;
336
393
  };
394
+ manifest: {
395
+ [K in keyof S as S[K]['id']]: {
396
+ key: string[];
397
+ write: boolean;
398
+ };
399
+ };
337
400
  };
338
401
  /** The routes one state router derives, typed for `createApi` and the route map. */
339
402
  type StateRoutes<S extends Record<string, RoutableState>> = ReadRoutes<S> & WriteRoutes<S> & {
@@ -377,4 +440,4 @@ type InferStateInput<R extends StateRouter<any>, TId extends string> = Shape<Sta
377
440
  */
378
441
  declare function defineStateRouter<S extends Record<string, RoutableState>>(states: S, options?: StateRouterOptions): StateRouter<S>;
379
442
 
380
- export { type ChangedPayload, type GrantTarget, type InferStateInput, type InferStateKey, type InferStateValue, type PermissionContext, type ReadContext, type RevokeTarget, type RoutableState, StateBuilder, type StateContract, type StateEvents, type StateFrame, type StateKey, type StateResult, type StateRouter, type StateRouterOptions, type StateRoutes, type StateShape, type StateSnapshot, type StateValue, type StateWriteOptions, type StatesSnapshot, type SubjectResolver, type WriteContext, defineState, defineStateRouter };
443
+ export { type ChangedPayload, type GrantTarget, type InferStateInput, type InferStateKey, type InferStateValue, type PermissionContext, type ReadContext, type RevokeTarget, type RoutableState, type SnapshotRouteOptions, StateBuilder, type StateContract, type StateEvents, type StateFrame, type StateKey, type StateResult, type StateRouter, type StateRouterOptions, type StateRoutes, type StateShape, type StateSnapshot, type StateValue, type StateWriteOptions, type StatesManifest, type StatesSnapshot, type SubjectResolver, type WriteContext, type WritePermissionContext, defineState, defineStateRouter };
@@ -1,5 +1,6 @@
1
1
  import { Type, Kind } from '@sinclair/typebox';
2
2
  import { Value } from '@sinclair/typebox/value';
3
+ import { getTransactionContext, onAfterCommit } from '@spfn/core/db';
3
4
  import { defineEvent } from '@spfn/core/event';
4
5
  import { logger } from '@spfn/core/logger';
5
6
  import { defineRouter, route } from '@spfn/core/route';
@@ -83,7 +84,12 @@ function toFrame(def, snapshot, subject, key) {
83
84
  }
84
85
  async function readAndPublish(def, event, subject, key, principal) {
85
86
  const snapshot = await readSnapshot(def, readContext(def, subject, principal, key));
86
- await event.emit(toFrame(def, snapshot, subject, key));
87
+ const frame = toFrame(def, snapshot, subject, key);
88
+ if (getTransactionContext()) {
89
+ onAfterCommit(() => event.emit(frame));
90
+ } else {
91
+ await event.emit(frame);
92
+ }
87
93
  return snapshot;
88
94
  }
89
95
  function framePayloadSchema(def) {
@@ -154,7 +160,7 @@ async function onSignal(state, payload) {
154
160
  try {
155
161
  await readAndPublish(state._definition, state._event, subject, key, void 0);
156
162
  } catch (error) {
157
- stateLogger.warn(`State "${state.id}" could not be re-read on its change signal; no frame was sent`, {
163
+ stateLogger.warn(`State "${state.id}" could not be re-read or published on its change signal; no frame was sent`, {
158
164
  state: state.id,
159
165
  error: error instanceof Error ? error.message : String(error)
160
166
  });
@@ -207,6 +213,16 @@ var StateBuilder = class _StateBuilder {
207
213
  this.assertOnce("data", !!this._definition.data);
208
214
  return this.with({ data: schema });
209
215
  }
216
+ /**
217
+ * Declare who the callbacks' `principal` is — what the app's auth
218
+ * middleware sets as `c.get('auth')`. Type-only, but for one rule: the write
219
+ * route refuses a request without a principal (401), so `.canWrite()` and
220
+ * `.write()` receive `P`; `.read()` and `.canRead()` receive `P | undefined`.
221
+ */
222
+ principal() {
223
+ this.assertOnce("principal", this._definition.principal);
224
+ return this.with({ principal: true });
225
+ }
210
226
  /**
211
227
  * Middleware for the two per-state routes, `get<Id>State` / `put<Id>State`.
212
228
  * Not authorization: the snapshot and the SSE stream cannot run it, so a
@@ -371,7 +387,7 @@ function defineState(id) {
371
387
  `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
388
  );
373
389
  }
374
- return new StateBuilder(id, { id, shared: false, middlewares: [] });
390
+ return new StateBuilder(id, { id, shared: false, principal: false, middlewares: [] });
375
391
  }
376
392
  var defaultSubject = (c) => c.get("auth")?.userId ?? null;
377
393
  function stateRouteName(method, id) {
@@ -384,19 +400,30 @@ function requireSubject(c, resolveSubject) {
384
400
  }
385
401
  return subject;
386
402
  }
403
+ function writePrincipal(def, c) {
404
+ const principal = c.get("auth");
405
+ if (def.principal && principal == null) {
406
+ throw new UnauthorizedError({
407
+ message: `State "${def.id}" declares .principal(), and this request carries none`
408
+ });
409
+ }
410
+ return principal;
411
+ }
387
412
  async function assertPermitted(def, can, ctx) {
388
413
  const predicate = can === "canWrite" ? def.canWrite ?? def.canRead : def.canRead;
389
414
  if (predicate && !await predicate(ctx)) {
390
415
  throw new ForbiddenError({ message: `${can === "canWrite" ? "Writing" : "Reading"} state "${def.id}" is not permitted` });
391
416
  }
392
417
  }
393
- function responseSchema(def) {
418
+ function resultSchema2(def) {
394
419
  return Type.Object({
395
- at: Type.String(),
396
420
  value: def.value,
397
421
  ...def.data ? { data: def.data } : {}
398
422
  });
399
423
  }
424
+ function responseSchema(def) {
425
+ return Type.Object({ at: Type.String(), ...resultSchema2(def).properties });
426
+ }
400
427
  function routeContract(def, write) {
401
428
  if (!def.contract) {
402
429
  return void 0;
@@ -431,7 +458,7 @@ function writeRoute(state, path, resolveSubject) {
431
458
  const write = def.write;
432
459
  return stateRoute(def, "put", `${path}/${def.id}`, { body: write.input, ...keyInput(def) }).handler(async (c) => {
433
460
  const subject = requireSubject(c.raw, resolveSubject);
434
- const principal = c.raw.get("auth");
461
+ const principal = writePrincipal(def, c.raw);
435
462
  const { body, query } = await c.data();
436
463
  const key = def.key ? pickKey(def.key, query) : void 0;
437
464
  await assertPermitted(def, "canWrite", permissionContext(def, subject, principal, key));
@@ -459,8 +486,28 @@ async function snapshotEntry(def, subject, principal, query, at) {
459
486
  const { value, data } = await readSnapshot(def, readContext(def, subject, principal, key), at);
460
487
  return def.data ? { value, data } : { value };
461
488
  }
462
- function snapshotRoute(states, path, resolveSubject) {
463
- return route.get(path).input({ query: snapshotQuery(states) }).handler(async (c) => {
489
+ function stateManifest(states) {
490
+ return Object.fromEntries(states.map((state) => [state.id, {
491
+ key: Object.keys(state._definition.key?.properties ?? {}),
492
+ write: state._definition.write !== void 0
493
+ }]));
494
+ }
495
+ function snapshotResponseSchema(states) {
496
+ const manifestEntry = Type.Object({ key: Type.Array(Type.String()), write: Type.Boolean() });
497
+ return Type.Object({
498
+ at: Type.String(),
499
+ states: Type.Object(Object.fromEntries(states.map((state) => [state.id, Type.Optional(resultSchema2(state._definition))]))),
500
+ manifest: Type.Object(Object.fromEntries(states.map((state) => [state.id, manifestEntry])))
501
+ });
502
+ }
503
+ function snapshotBuilder(states, path, options) {
504
+ const builder = route.get(path).input({ query: snapshotQuery(states) });
505
+ const used = options.use?.length ? builder.use(options.use) : builder;
506
+ const skipped = options.skip ? used.skip(options.skip) : used;
507
+ return options.contract ? skipped.contract(stripUndefined({ ...options.contract, response: snapshotResponseSchema(states) })) : skipped;
508
+ }
509
+ function snapshotRoute(states, manifest, path, resolveSubject, options = {}) {
510
+ return snapshotBuilder(states, path, options).handler(async (c) => {
464
511
  const subject = requireSubject(c.raw, resolveSubject);
465
512
  const principal = c.raw.get("auth");
466
513
  const { query } = await c.data();
@@ -468,7 +515,8 @@ function snapshotRoute(states, path, resolveSubject) {
468
515
  const entries = await Promise.all(states.map((state) => snapshotEntry(state._definition, subject, principal, query, at)));
469
516
  return {
470
517
  at,
471
- states: Object.fromEntries(states.map((state, index) => [state.id, entries[index]]).filter(([, entry]) => entry !== void 0))
518
+ states: Object.fromEntries(states.map((state, index) => [state.id, entries[index]]).filter(([, entry]) => entry !== void 0)),
519
+ manifest
472
520
  };
473
521
  });
474
522
  }
@@ -479,17 +527,19 @@ function defineStateRouter(states, options = {}) {
479
527
  const list = Object.values(states);
480
528
  assertPath(path);
481
529
  list.forEach(assertRoutable);
530
+ assertSnapshotMiddlewareIsNotAuthorization(list, options);
482
531
  assertDistinctIds(list);
483
532
  list.forEach((state) => assertNotRouted(state, path));
484
533
  list.forEach((state) => bindState(state._definition, path));
485
534
  list.forEach(subscribeChangeSignals);
486
535
  return {
487
- router: defineRouter(deriveRoutes(list, path, options.subject ?? defaultSubject)),
536
+ router: defineRouter(deriveRoutes(list, path, options)),
488
537
  events: Object.fromEntries(list.map((state) => [stateEventName(state.id), state._event])),
489
538
  states
490
539
  };
491
540
  }
492
- function deriveRoutes(states, path, resolveSubject) {
541
+ function deriveRoutes(states, path, options) {
542
+ const resolveSubject = options.subject ?? defaultSubject;
493
543
  const routes = {};
494
544
  for (const state of states) {
495
545
  routes[stateRouteName("get", state.id)] = readRoute(state, path, resolveSubject);
@@ -497,7 +547,7 @@ function deriveRoutes(states, path, resolveSubject) {
497
547
  routes[stateRouteName("put", state.id)] = writeRoute(state, path, resolveSubject);
498
548
  }
499
549
  }
500
- routes.getStates = snapshotRoute(states, path, resolveSubject);
550
+ routes.getStates = snapshotRoute(states, stateManifest(states), path, resolveSubject, options);
501
551
  return routes;
502
552
  }
503
553
  function assertPath(path) {
@@ -536,6 +586,14 @@ function assertMiddlewareIsNotAuthorization(state) {
536
586
  );
537
587
  }
538
588
  }
589
+ function assertSnapshotMiddlewareIsNotAuthorization(states, options) {
590
+ const unguarded = states.find((state) => !state._definition.canRead);
591
+ if (((options.use?.length ?? 0) > 0 || options.skip) && unguarded) {
592
+ throw new Error(
593
+ `defineStateRouter: the router gives the snapshot route use or skip, but state "${unguarded.id}" declares no .canRead(). The snapshot's middleware never reaches the SSE stream, which serves the same state, so it cannot authorize. Authorization belongs in .canRead(); a state readable by every subject declares .canRead(() => true) to say so.`
594
+ );
595
+ }
596
+ }
539
597
  function assertChangedBy(state, event) {
540
598
  const payload = event.schema;
541
599
  const signal = changedPayloadSchema(state._definition);