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

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.
@@ -44,7 +44,7 @@ From `@spfn/core/db`:
44
44
  - `runInTransaction(callback, options?)` — run a callback in a transaction (scripts/CLI; no Hono).
45
45
  - `onBeforeCommit(callback)` — run work inside the root transaction, just before it commits.
46
46
  - `onAfterCommit(callback)` — defer a side effect until after the root transaction commits.
47
- - `onAfterRollback(callback)` — compensate after the root transaction rolled back.
47
+ - `onAfterRollback(callback)` — compensate after the work it was registered with rolled back.
48
48
  - `getTransaction()` — current transaction `TransactionDB`, or `null`.
49
49
  - `getTransactionContext()` — current `TransactionContext` (`tx`, `txId`, `level`, callbacks), or `null`.
50
50
  - `runWithTransaction(tx, txId, callback)` — bind a `tx` into `AsyncLocalStorage` for a callback.
@@ -305,9 +305,12 @@ Behavior to know:
305
305
  against a row the outer transaction locked.
306
306
  - **One commit.** Nested writes are durable only when the **root** commits. There is no
307
307
  intermediate commit at a savepoint boundary.
308
- - The inner level shares **all three of the root's** hook queues, so a hook registered in
309
- a nested call fires at the **root's** boundary, not the savepoint's. See
310
- [Transaction hooks](#transaction-hooks).
308
+ - The inner level has **hook queues of its own**: a hook belongs to the savepoint that
309
+ registered it. A savepoint that resolves hands its hooks to its parent; one that rolls
310
+ back runs its `onAfterRollback` callbacks right there — inside the **parent's** context,
311
+ the root still open — and drops its `onBeforeCommit` / `onAfterCommit` callbacks, even
312
+ when the root goes on to commit. See
313
+ [Hooks belong to the frame that registered them](#hooks-belong-to-the-frame-that-registered-them).
311
314
  - **One at a time.** Nested calls made off the same transaction are serialized: a frame
312
315
  opens only after the previous frame on that connection has closed. See
313
316
  [Concurrent nested calls](#concurrent-nested-calls-are-serialized).
@@ -453,23 +456,25 @@ logging the SQL it emits — worth knowing before nesting deeply or in a loop:
453
456
 
454
457
  ## Transaction hooks
455
458
 
456
- Three hooks attach work to the **root** transaction's lifecycle. All three take
459
+ Three hooks attach work to a transaction's lifecycle. All three take
457
460
  `() => void | Promise<void>`, are registered the same way from anywhere inside the async
458
- call chain, and queue on the root context (`BeforeCommitCallback`, `AfterCommitCallback`,
459
- `AfterRollbackCallback` are the exported type aliases).
461
+ call chain, and queue on the **frame** — root or savepoint — they were registered in
462
+ (`BeforeCommitCallback`, `AfterCommitCallback`, `AfterRollbackCallback` are the exported
463
+ type aliases). A frame's queues reach the root only through savepoints that resolved; see
464
+ [Hooks belong to the frame that registered them](#hooks-belong-to-the-frame-that-registered-them).
460
465
 
461
466
  | Hook | Runs | Still inside the tx? | If the callback throws |
462
467
  |------|------|----------------------|------------------------|
463
468
  | `onBeforeCommit(cb)` | after the root callback resolves, **before** `COMMIT` | **yes** — may run statements | aborts: later callbacks skipped, transaction rolls back, error propagates, `afterRollback` fires |
464
469
  | `onAfterCommit(cb)` | after the root transaction committed | no | logged, never thrown |
465
- | `onAfterRollback(cb)` | after the root transaction rolled back | no | logged, never thrown; the **original** error keeps propagating unchanged |
470
+ | `onAfterRollback(cb)` | after the root transaction rolled back — or, registered in a savepoint that rolled back, right after that savepoint did | no after a root rollback; after a savepoint rollback it runs inside the **parent's** still-open transaction | logged, never thrown; the **original** error keeps propagating unchanged |
466
471
 
467
472
  Registration context:
468
473
 
469
474
  | Context | `onBeforeCommit` | `onAfterCommit` | `onAfterRollback` |
470
475
  |---------|------------------|-----------------|-------------------|
471
476
  | Inside root transaction | Queued; runs before the root's commit | Queued; runs after the root commits | Queued; runs if the root rolls back |
472
- | Inside a nested transaction | Queued on the **root** queue | Queued on the **root** queue | Queued on the **root** queue |
477
+ | Inside a nested transaction | Queued on the savepoint; passed to the parent if it resolves, dropped if it rolls back | Queued on the savepoint; passed to the parent if it resolves, dropped if it rolls back | Queued on the savepoint; passed to the parent if it resolves, **run at once** if it rolls back |
473
478
  | Outside any transaction | Runs immediately on a microtask **+ `warn` log** — nothing left to commit, so a throw aborts nothing | Runs immediately on a microtask (already "committed") | **No-op + `warn` log** — there is no rollback to wait for |
474
479
 
475
480
  ### `onBeforeCommit(callback)`
@@ -567,11 +572,16 @@ async function importAvatar(userId: string, file: Blob)
567
572
 
568
573
  - Fires on **any** root rollback: a thrown handler error, a thrown `onBeforeCommit`
569
574
  callback, a statement timeout, a constraint violation.
570
- - Callbacks run **outside** the transaction context (`getTransaction()` is `null`), after
571
- the driver rolled back — DB work in them uses a fresh connection, like `onAfterCommit`.
575
+ - Registered inside a savepoint, it fires when **that savepoint** rolls back — at that
576
+ moment, whatever the root does afterwards — and not again at the root.
577
+ - After a **root** rollback, callbacks run **outside** the transaction context
578
+ (`getTransaction()` is `null`) — DB work in them uses a fresh connection, like
579
+ `onAfterCommit`. After a **savepoint** rollback the root is still open and they run
580
+ inside the **parent's** context: `getTransaction()` is the parent's `tx`, and DB work in
581
+ them joins the parent transaction — it commits only if the parent does.
572
582
  - They are **awaited**, in registration order, before the causing error leaves
573
- `runInTransaction` / the middleware. A callback that hangs delays that error, so keep
574
- them short.
583
+ `runInTransaction` / the middleware (for a savepoint: the nested call). A callback that
584
+ hangs delays that error, so keep them short.
575
585
  - Errors are logged and swallowed: a failing callback never replaces the error that caused
576
586
  the rollback, never stops the remaining callbacks, and neither does a failure of the log
577
587
  call itself.
@@ -580,28 +590,41 @@ async function importAvatar(userId: string, file: Blob)
580
590
  to the client, and these callbacks fire — so a compensation should be idempotent and
581
591
  safe to run against data that did, in the end, land.
582
592
 
583
- ### Hooks are scoped to the root transaction
584
-
585
- Every queue lives on the root context and nested contexts share it, so the **root's** fate
586
- decides everything:
587
-
588
- - A nested transaction that rolls back while the **root commits** fires **no**
589
- `afterRollback` callbacks — not the ones registered nested, not the ones registered at the
590
- root. The hooks answer "did the root transaction survive?", and it did. Compensating for a
591
- nested failure the outer code deliberately caught and recovered from is that code's job.
592
- - Conversely, hooks registered in a nested call fire exactly once, at the root's boundary —
593
- never once per nesting level.
594
- - **Registering compensations from a nested call is correct for the root's fate — and only
595
- for it.** A nested call is a SAVEPOINT: its writes are durable only if the root commits,
596
- so when the nested call *succeeded* and the root later rolls back, an `onAfterRollback`
597
- registered inside it fires exactly when the work it compensates for disappears, and stays
598
- silent when the root commits that work.
599
- It is **not** a general "fires when my writes disappear" hook: in the caught-rollback case
600
- above, the nested writes are gone and no hook fires at all. Code that catches a nested
601
- failure and carries on must compensate **in the catch block**, at the moment it decides to
602
- recover; deferring that to `onAfterRollback` compensates for nothing.
603
- - A `requiresNew: true` call is the exception, because it is a root: hooks registered inside
604
- it belong to it and fire on its own commit or rollback.
593
+ ### Hooks belong to the frame that registered them
594
+
595
+ Every frame — the root and each savepoint — has queues of its own, and its **own** fate
596
+ decides what happens to the hooks registered in it:
597
+
598
+ - **A savepoint that resolves** appends its three queues to its parent's, after what the
599
+ parent registered before the nested call and before what it registers after. Its hooks
600
+ now share the parent's fate: `onBeforeCommit` still runs before the **root's** `COMMIT`
601
+ (not at the savepoint — the driver never releases one), `onAfterCommit` after the root
602
+ commits, `onAfterRollback` if the root rolls back. Promotion repeats level by level, so a
603
+ hook fires exactly once, and only if every savepoint between it and the root resolved.
604
+ - **A savepoint that rolls back** drops its `onBeforeCommit` and `onAfterCommit`
605
+ callbacks — the work they guard or announce is gone — and runs its `onAfterRollback`
606
+ callbacks immediately, awaited before the error leaves the nested call, inside the
607
+ parent's still-open transaction. Hooks promoted into it from savepoints nested inside it
608
+ go the same way. A compensation may call `runInTransaction` itself: that opens a savepoint
609
+ on the parent, and a hook the compensation registers belongs to the parent.
610
+ - **A statement that fails inside a savepoint's `onAfterRollback` aborts the parent.** The
611
+ root-rollback pass runs outside any transaction, so a failing statement there touches
612
+ nothing; the savepoint pass runs inside the parent's transaction, so a failing statement
613
+ puts it into the aborted state (`25P02`). The callback's error is logged and swallowed as
614
+ always, but the root's next statement then fails with `25P02` (current transaction is
615
+ aborted). If the root runs none, its `COMMIT` fails with the callback's error. Either way,
616
+ everything the root wrote rolls back. Wrap DB work in a compensation in its own
617
+ `runInTransaction` (a savepoint) when it may fail.
618
+ - **The root** runs whatever reached it. A savepoint failure the root catches and survives
619
+ fires none of the **root's** own `afterRollback` callbacks: the root did not roll back.
620
+ - A `requiresNew: true` call is a root on its own connection: hooks registered inside it
621
+ belong to it and fire on its own commit or rollback, never handed to the outer
622
+ transaction.
623
+ - **A hook registered after its frame has settled is dropped**, the same as one registered
624
+ after a root has settled. A frame's queues are handed on (or discarded) once, at the
625
+ moment it settles; detached work it started — a `setTimeout`, an un-awaited promise —
626
+ that registers a hook later appends to a queue nobody reads again. Register hooks in the
627
+ awaited call chain of the frame they belong to.
605
628
 
606
629
  ### Why there is no `onBeforeRollback`
607
630
 
@@ -45,12 +45,13 @@ From `@spfn/core/event`:
45
45
  - `.contract({ since, deprecatedIn?, removedIn? })` on an event and `.contract({ auth, streamPath? })`
46
46
  on an event router — publish the event in `contracts/current.json` (see
47
47
  [Contracted events](#contracted-events-for-separately-compiled-clients))
48
- - `.canSubscribe(predicate)`, `.filter(predicate)`, `.grant(subject)`, `.revoke(subject)` on an
49
- event — who may subscribe over SSE, which frames a connection receives, and changing an open
50
- connection (see [Per-event permission](#per-event-permission-cansubscribe-filter-grant--revoke))
48
+ - `.canSubscribe(predicate)`, `.filter(predicate)`, `.initial(producer)`, `.grant(subject)`,
49
+ `.revoke(subject)` on an event — who may subscribe over SSE, which frames a connection receives,
50
+ the frames it receives right after `connected`, and changing an open connection (see
51
+ [Per-event permission](#per-event-permission-cansubscribe-filter-initial-grant--revoke))
51
52
  - Types: `EventDef`, `EventHandler`, `EventSubscribeOptions`, `InferEventPayload`, `PubSubCache`, `JobQueueSender`,
52
53
  `EventContract`, `EventRouterContract`, `SubscribePredicate`, `SubscribeContext`,
53
- `SubscribeRequest`, `FramePredicate`, `FrameContext`,
54
+ `SubscribeRequest`, `FramePredicate`, `FrameContext`, `InitialProducer`,
54
55
  `EventRouterDef`, `InferEventNames`, `InferEventPayloads`,
55
56
  `InferRouterEventPayload` (the two-arg router variant — see note)
56
57
 
@@ -61,7 +62,7 @@ From `@spfn/core/event/sse`:
61
62
  - Types: `SSEToken`, `SSETokenStore`, `SSETokenManagerConfig`, `SSEMessage`,
62
63
  `SSEHandlerConfig`, `SSEHandlerAuthConfig`, `SSEAuthConfig`, `SSEClientConfig`,
63
64
  `SSEEventHandler`, `SSEEventHandlers`, `SSESubscribeOptions`, `SSESubscriptionChange`,
64
- `SSEConnectionState`, `SSEUnsubscribe`
65
+ `SSEConnectionState`, `SSEUnsubscribe`, `ConnectedPayload`
65
66
 
66
67
  From `@spfn/core/event/sse/client`:
67
68
 
@@ -139,6 +140,7 @@ Promise<void>` otherwise. Handlers run via `Promise.allSettled` — see
139
140
  | `contract(contract)` | `(EventContract) => EventDef` | publish in the contract; returns the same event; a second call throws |
140
141
  | `canSubscribe(predicate)` | `<P>(SubscribePredicate<P>) => EventDef` | who may subscribe over SSE (once per connection); a second call throws |
141
142
  | `filter(predicate)` | `(FramePredicate<TPayload>) => EventDef` | which frames a connection receives (sync, per frame); a second call throws |
143
+ | `initial(producer)` | `<P>(InitialProducer<TPayload, P>) => EventDef` | the frames a connection receives right after `connected`, before any live frame (once per connection with a subject); a second call throws |
142
144
  | `grant(subject, match?)` | `Promise<void>` | activate the event on the subject's open SSE connections (those matching `match`, when given) where it is denied |
143
145
  | `revoke(subject, match?)` | `Promise<void>` | deactivate the event on the subject's open SSE connections (those matching `match`, when given) where it is allowed |
144
146
 
@@ -274,7 +276,9 @@ Client Server
274
276
  │ │ → authorize(subject, events)
275
277
  │ │ → resolvePrincipal(subject, request) (once)
276
278
  │ │ → event.canSubscribe(ctx) (once per event)
277
- │ ◄═══════════════════════════════ │ SSE stream opens (connected: subscribed + denied)
279
+ │ │ → event.initial(ctx) (once per allowed event; live frames held)
280
+ │ ◄═══════════════════════════════ │ SSE stream opens (connected: subscribed + denied + initial)
281
+ │ event: <name> / data: {…} │ ← each initial frame, then the held live frames
278
282
  │ event: <name> / data: {…} │ ← filter(subject, payload) + event.filter(payload, ctx) per emission
279
283
  ```
280
284
 
@@ -393,7 +397,7 @@ Return `false` to skip. Payload is typed per event.
393
397
  })
394
398
  ```
395
399
 
396
- ### Per-event permission (`canSubscribe`, `filter`, `grant` / `revoke`)
400
+ ### Per-event permission (`canSubscribe`, `filter`, `initial`, `grant` / `revoke`)
397
401
 
398
402
  The global hooks above are one function for the whole router. When the rule belongs to one event,
399
403
  declare it where the event is defined:
@@ -403,7 +407,8 @@ export const auditLogged = defineEvent('audit.logged', payload)
403
407
  .canSubscribe<AuthPrincipal>(({ subject, principal, request }) => principal?.role === 'admin');
404
408
 
405
409
  export const runtimeChanged = defineEvent('runtime.changed', payload)
406
- .filter((payload, { subject, request }) => payload.workspaceId === request.query.workspaceId);
410
+ .filter((payload, { subject, request }) => payload.workspaceId === request.query.workspaceId)
411
+ .initial(async ({ request }) => [await runtimes.current(request.query.workspaceId)]);
407
412
  ```
408
413
 
409
414
  - **`canSubscribe(ctx)`** — once per connection per requested event, only when the connection has a
@@ -414,11 +419,26 @@ export const runtimeChanged = defineEvent('runtime.changed', payload)
414
419
  - **`filter(payload, ctx)`** — per frame, with or without a subject; it matches the payload against
415
420
  the connect request. It runs beside the global `filter` map — both must return `true`. Sync. A
416
421
  throw drops that frame (warn) and leaves the connection alive.
417
- - **`request`** in both is a snapshot taken once at connect: `{ query, headers }` with lowercased
422
+ - **`initial(ctx)`** — the event's current frames for one connection, written right after the
423
+ `connected` frame and before any live frame, so a client that opens the stream needs no read of
424
+ its own. It runs once per connection **with a subject**, only for an event `canSubscribe` allowed,
425
+ with the same `{ subject, principal, request }`; the producers of one connection run in
426
+ parallel, and their frames are written in the order the events were requested. It returns an
427
+ array of payloads (`[]` sends nothing but still counts as served). Its frames skip both filters —
428
+ they were produced for this connection — so the producer must return only what this subject may
429
+ see: neither the global `filter` nor `.filter()` runs on its frames. They number their
430
+ `messageId`s like any frame. The
431
+ `connected` frame's `initial` lists exactly the events whose producer returned; a producer that
432
+ throws or rejects is logged at warn with the event name (never the error) and sends nothing,
433
+ and the stream stays open. While the producers run, live frames — and `grant` / `revoke` frames
434
+ and pings — are held, then written after the initial frames in arrival order; a `revoke` landing
435
+ meanwhile still stops the subscription at once. A `grant` later never runs it. A connection
436
+ without a subject, or with no allowed event that declares it, gets `initial: []`.
437
+ - **`request`** in all three is a snapshot taken once at connect: `{ query, headers }` with lowercased
418
438
  header names — never the live Hono context. It is the connect request as sent: it holds the
419
439
  `cookie` / `authorization` headers and the consumed one-time `token` in `query`, so the app must
420
440
  never log the snapshot.
421
- - Both are set once per event: an `EventDef` is a shared singleton, so a second call throws.
441
+ - Each is set once per event: an `EventDef` is a shared singleton, so a second call throws.
422
442
 
423
443
  **`resolvePrincipal` — why the stream has no `AuthContext`.** The subject comes from the one-time
424
444
  token, and the stream is mounted without the app's middleware, so there is no request auth object
@@ -441,10 +461,17 @@ resolved' }`.
441
461
  kept → per frame, the global `filter` and the event's `filter`. An event `authorize` dropped is
442
462
  never asked and counts as denied too.
443
463
 
444
- **Cost.** At connect: one predicate call per requested event (plus one `resolvePrincipal`). Per
445
- frame: sync filters only — never storage. The handler sets no timeout on them: a hanging
446
- `canSubscribe` or `resolvePrincipal` holds the connect open, so the app owns the timeout. A
447
- client that leaves meanwhile is forgotten at once; the late verdict opens nothing.
464
+ **Cost.** At connect: one predicate call per requested event (plus one `resolvePrincipal`), and
465
+ one `initial` producer call per allowed event that declares one. Per frame: sync filters only —
466
+ never storage. The handler sets no timeout on them: a hanging `canSubscribe` or
467
+ `resolvePrincipal` holds the connect open, and a hanging `initial` holds the `connected` frame and
468
+ every live frame behind it, so the app owns the timeout. A client that leaves meanwhile is
469
+ forgotten at once; the late verdict opens nothing, and late initial frames are not written. The
470
+ initial frames and the held live frames go through the connection's bounded queue like any frame:
471
+ a held frame counts against `maxQueue` from the moment it is held, together with the frames
472
+ already queued, and past it the connection is closed as a slow consumer — while the producers
473
+ still run, too — and the client reconnects. A producer's duration thus counts as the client's
474
+ slowness: frames that pile up while it runs count as frames the client has not read.
448
475
 
449
476
  **`grant` / `revoke` — changing an open connection.**
450
477
 
@@ -501,6 +528,22 @@ client.subscribe({
501
528
  });
502
529
  ```
503
530
 
531
+ The `connected` frame — always the first of a connection — carries
532
+ `{ subscribedEvents, deniedEvents, initial, timestamp }` (`ConnectedPayload`; `initial` is absent
533
+ from a server that predates `.initial()`). `onConnected` receives it on every connection, the first
534
+ and each reconnect, after `onOpen`:
535
+
536
+ ```typescript
537
+ client.subscribe({
538
+ events: ['runtimeChanged'],
539
+ handlers: { runtimeChanged: (p) => update(p) },
540
+ onConnected: ({ deniedEvents, initial }) =>
541
+ {
542
+ // initial: the events whose current frames follow this one; read the others yourself
543
+ },
544
+ });
545
+ ```
546
+
504
547
  ### SSE auth config (`SSEAuthConfig<TRouter>`)
505
548
 
506
549
  | Option | Type | Default | Description |
@@ -891,6 +934,8 @@ interface EventDef<TPayload = void> {
891
934
  canSubscribe: <P = unknown>(predicate: SubscribePredicate<P>) => EventDef<TPayload>;
892
935
  readonly _filter?: FramePredicate<TPayload>;
893
936
  filter: (predicate: FramePredicate<TPayload>) => EventDef<TPayload>;
937
+ readonly _initial?: InitialProducer<TPayload, any>;
938
+ initial: <P = unknown>(producer: InitialProducer<TPayload, P>) => EventDef<TPayload>;
894
939
  grant: (subject: string, match?: SubscriptionMatch) => Promise<void>;
895
940
  revoke: (subject: string, match?: SubscriptionMatch) => Promise<void>;
896
941
  }
@@ -901,6 +946,8 @@ interface SubscribeContext<P = unknown> { subject: string; principal: P | undefi
901
946
  type SubscribePredicate<P = unknown> = (ctx: SubscribeContext<P>) => boolean | Promise<boolean>;
902
947
  interface FrameContext { subject: string | undefined; request: SubscribeRequest; }
903
948
  type FramePredicate<TPayload> = (payload: TPayload, ctx: FrameContext) => boolean;
949
+ type InitialProducer<TPayload, P = unknown> = (ctx: SubscribeContext<P>) => TPayload[] | Promise<TPayload[]>;
950
+ interface ConnectedPayload { subscribedEvents: string[]; deniedEvents: string[]; initial?: string[]; timestamp: number; }
904
951
 
905
952
  interface EventContract { since: string; deprecatedIn?: string; removedIn?: string; }
906
953