@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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.
Files changed (87) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  5. package/src/api/__tests__/sse-broker.test.ts +49 -0
  6. package/src/api/redis-sse-broker.ts +17 -3
  7. package/src/api/request-context.ts +29 -4
  8. package/src/api/routes.ts +26 -1
  9. package/src/api/sse-broker.ts +29 -11
  10. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  11. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  12. package/src/bun-db/query.ts +42 -18
  13. package/src/changes.json +92 -0
  14. package/src/db/__tests__/pg-error.test.ts +14 -0
  15. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  16. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  17. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  18. package/src/db/index.ts +1 -1
  19. package/src/db/pg-error.ts +13 -0
  20. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  21. package/src/db/queries/event-consumer.ts +57 -3
  22. package/src/db/queries/event-store.ts +69 -0
  23. package/src/db/tenant-db.ts +133 -18
  24. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  25. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  26. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  27. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  28. package/src/engine/extension-names.ts +55 -25
  29. package/src/engine/extensions/storage-provider.ts +14 -41
  30. package/src/engine/extensions/tenant-data.ts +4 -0
  31. package/src/engine/extensions/tenant-resource.ts +40 -0
  32. package/src/engine/extensions/user-data.ts +8 -7
  33. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  34. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  35. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  36. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  37. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  38. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  39. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  40. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  41. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  42. package/src/engine/feature-ast/index.ts +11 -1
  43. package/src/engine/feature-ast/patch.ts +338 -5
  44. package/src/engine/feature-ast/patcher.ts +2 -2
  45. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  46. package/src/engine/feature-ast/patterns.ts +22 -15
  47. package/src/engine/feature-ast/render.ts +1 -0
  48. package/src/engine/feature-ui-extensions.ts +8 -7
  49. package/src/engine/index.ts +21 -5
  50. package/src/engine/types/extension-options-map.ts +1 -0
  51. package/src/engine/types/index.ts +6 -0
  52. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  53. package/src/event-store/admin-api.ts +5 -0
  54. package/src/event-store/event-store.ts +16 -7
  55. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  56. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  57. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  58. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  59. package/src/jobs/job-runner.ts +211 -19
  60. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  61. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  62. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  63. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  64. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  65. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  66. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  67. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  68. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  69. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  70. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  71. package/src/pipeline/dispatch-batch.ts +102 -29
  72. package/src/pipeline/dispatch-stream.ts +7 -3
  73. package/src/pipeline/dispatcher-utils.ts +21 -2
  74. package/src/pipeline/dispatcher.ts +71 -6
  75. package/src/pipeline/event-consumer-state.ts +26 -0
  76. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  77. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  78. package/src/pipeline/event-dispatcher.ts +167 -50
  79. package/src/pipeline/idempotency.ts +16 -0
  80. package/src/pipeline/pending-gap-ranges.ts +72 -0
  81. package/src/pipeline/system-hooks.ts +8 -1
  82. package/src/pipeline/system-identity-switch.ts +22 -4
  83. package/src/pipeline/write-origin.ts +31 -10
  84. package/src/stack/test-stack.ts +1 -1
  85. package/src/testing/closed-connection-error.ts +62 -0
  86. package/src/testing/index.ts +1 -0
  87. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -1,4 +1,5 @@
1
1
  import type { DbTx, PgClient } from "../db/connection";
2
+ import { selectSnapshotXmax, selectSnapshotXmin } from "../db/queries/event-consumer";
2
3
  import type { AppContext } from "../engine/types";
3
4
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
4
5
  import { EVENTS_PUBSUB_CHANNEL, type StoredEvent } from "../event-store";
@@ -11,18 +12,21 @@ import {
11
12
  type Meter,
12
13
  type Tracer,
13
14
  } from "../observability";
14
- import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
15
+ import { type PendingGapEntry, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
15
16
  import {
16
17
  acquireConsumerState,
17
18
  consumerInstanceId,
19
+ type DeliveryOutcome,
18
20
  deliverEvents,
19
21
  emitLagFromTx,
20
22
  fetchPendingEvents,
21
23
  markProcessing,
24
+ type PersistedConsumerOutcome,
22
25
  persistConsumerOutcome,
23
26
  persistConsumerPassFailure,
24
27
  preRegisterConsumers,
25
28
  } from "./event-dispatcher-delivery";
29
+ import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
26
30
 
27
31
  // Async event-dispatcher — the "AsyncDaemon"-pendant for Kumiko.
28
32
  //
@@ -37,7 +41,9 @@ import {
37
41
  // 2. SELECT state row FOR UPDATE SKIP LOCKED
38
42
  // — multi-instance-safe: if another poller holds the lock, this pass
39
43
  // skips this consumer and tries the next. No duplicate delivery.
40
- // 3. SELECT events WHERE id > lastProcessedEventId ORDER BY id ASC LIMIT batchSize
44
+ // 3. SELECT events WHERE id > lastProcessedEventId, PLUS any ranges still
45
+ // tracked in pending_gaps (ids invisible on an earlier turn that may
46
+ // have committed since) — ORDER BY id ASC LIMIT batchSize
41
47
  // 4. For each event: call the consumer's handler
42
48
  // - handler throws → increment attempts, mark status="dead" at
43
49
  // maxAttempts, surface lastError, STOP this consumer's pass
@@ -255,32 +261,96 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
255
261
  // NOTIFYs (subscription drop, crash mid-commit).
256
262
  let pgUnlisten: (() => Promise<void>) | null = null;
257
263
 
258
- // Serialises concurrent runOnce() calls from both wake-up sources (timer
259
- // + any future explicit nudge). Mirrors outbox-poller's passInFlight
260
- // pattern so behaviour under races stays predictable.
261
- let passInFlight: Promise<DispatcherPassResult> | null = null;
264
+ // Bounds how many consumer turns hold a DB transaction at once. Each turn's
265
+ // db.begin() checks out one pool connection, plus any writes the handler
266
+ // itself makes via context.db — the app db pool defaults to postgres-js's
267
+ // max=10 (db/connection.ts, no DATABASE_POOL_MAX override) and is shared
268
+ // with HTTP request handlers, so turns can't be allowed to claim it all.
269
+ // A pool no larger than this limit can deadlock: every connection held by
270
+ // an open turn TX while each handler waits for one more.
271
+ const MAX_CONCURRENT_CONSUMER_TURNS = 4;
272
+ let activeConsumerTurns = 0;
273
+ const consumerTurnWaiters: Array<() => void> = [];
274
+
275
+ async function acquireConsumerTurnSlot(): Promise<() => void> {
276
+ if (activeConsumerTurns >= MAX_CONCURRENT_CONSUMER_TURNS) {
277
+ // The releasing turn hands its slot over directly (count unchanged),
278
+ // so a synchronous acquire in between cannot overshoot the limit.
279
+ await new Promise<void>((resolve) => consumerTurnWaiters.push(resolve));
280
+ } else {
281
+ activeConsumerTurns++;
282
+ }
283
+ let released = false;
284
+ return () => {
285
+ // skip: already released — finally-blocks calling this twice must not
286
+ // free the same slot twice
287
+ if (released) return;
288
+ released = true;
289
+ const nextWaiter = consumerTurnWaiters.shift();
290
+ if (nextWaiter) nextWaiter();
291
+ else activeConsumerTurns--;
292
+ };
293
+ }
262
294
 
263
- async function drainPassInFlight(): Promise<void> {
264
- if (passInFlight) {
265
- await passInFlight.catch(() => {
266
- // skip: errors already recorded per-consumer inside the pass
267
- });
295
+ // Per-(consumer, instanceId) turn guard, so one slow consumer never holds
296
+ // back the others. Keyed like consumerBackoff: a consumer already running
297
+ // its turn hands back that same promise instead of starting a second one.
298
+ const inFlightTurns = new Map<string, Promise<{ processed: number; failed: number }>>();
299
+
300
+ async function runConsumerTurn(
301
+ consumer: EventConsumer,
302
+ effective: ReadonlySet<string> | undefined,
303
+ ): Promise<{ processed: number; failed: number }> {
304
+ const key = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
305
+ const existing = inFlightTurns.get(key);
306
+ if (existing) return existing;
307
+
308
+ // Feature-gate and backoff-gate are resolved synchronously, before any
309
+ // promise is registered — a gated consumer must resolve without ever
310
+ // occupying inFlightTurns, or it would look "still running" forever.
311
+ if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
312
+ return { processed: 0, failed: 0 };
313
+ }
314
+ const backoff = consumerBackoff.get(key);
315
+ if (backoff && backoff.retryAtMs > Date.now()) {
316
+ return { processed: 0, failed: 0 };
317
+ }
318
+
319
+ // Registered before awaiting the slot — a turn queued behind the
320
+ // concurrency limit still counts as in-flight, so it isn't started twice.
321
+ const turn = (async () => {
322
+ const releaseSlot = await acquireConsumerTurnSlot();
323
+ try {
324
+ return await processConsumer(consumer);
325
+ } finally {
326
+ releaseSlot();
327
+ }
328
+ })();
329
+ inFlightTurns.set(key, turn);
330
+ try {
331
+ return await turn;
332
+ } finally {
333
+ if (inFlightTurns.get(key) === turn) inFlightTurns.delete(key);
268
334
  }
269
335
  }
270
336
 
337
+ async function drainInFlightTurns(): Promise<void> {
338
+ await Promise.all(
339
+ [...inFlightTurns.values()].map((turn) =>
340
+ turn.catch(() => {
341
+ // skip: errors already recorded per-consumer inside the pass
342
+ }),
343
+ ),
344
+ );
345
+ }
346
+
271
347
  async function runOnce(): Promise<DispatcherPassResult> {
272
348
  if (!preRegistered) {
273
349
  throw new Error(
274
350
  "EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
275
351
  );
276
352
  }
277
- if (passInFlight) return passInFlight;
278
- passInFlight = doPass();
279
- try {
280
- return await passInFlight;
281
- } finally {
282
- passInFlight = null;
283
- }
353
+ return doPass();
284
354
  }
285
355
 
286
356
  async function doPass(): Promise<DispatcherPassResult> {
@@ -301,30 +371,17 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
301
371
  // SYSTEM_TENANT_ID returnt (typisch: union-of-all-tier-features).
302
372
  const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
303
373
 
304
- // Seriell pro consumer. Parallelisierung wäre möglich (je eigene TX), aber
305
- // das einfache Modell reicht für v1 — jeder consumer hat geringe
306
- // per-event-Arbeit (network call at worst). Bei hunderten Events pro
307
- // Batch lohnt sich Parallelisierung — Optimierung für später.
308
- for (const consumer of consumers) {
309
- // Feature-gate: consumers tagged with a featureName get paused while
310
- // that feature is globally disabled. Cursor stays put — events accumulate
311
- // and are re-delivered in order when the feature is re-enabled.
312
- if (effective && consumer.featureName && !effective.has(consumer.featureName)) {
313
- byConsumer[consumer.name] = { processed: 0, failed: 0 };
314
- continue;
315
- }
316
- // Backoff-gate: a consumer whose last pass threw waits out its
317
- // exponential delay before being tried again — see the catch in
318
- // processConsumer. Other consumers are unaffected; only this one's
319
- // turn is skipped this tick.
320
- const backoffKey = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
321
- const backoff = consumerBackoff.get(backoffKey);
322
- if (backoff && backoff.retryAtMs > Date.now()) {
323
- byConsumer[consumer.name] = { processed: 0, failed: 0 };
324
- continue;
325
- }
326
- const perConsumer = await processConsumer(consumer);
327
- byConsumer[consumer.name] = perConsumer;
374
+ // Every consumer runs its own turn concurrently (own TX, own cursor,
375
+ // own backoff/feature-gate) — a slow consumer no longer blocks the
376
+ // others' delivery until it commits, see runConsumerTurn.
377
+ const results = await Promise.all(
378
+ consumers.map(async (consumer) => {
379
+ const perConsumer = await runConsumerTurn(consumer, effective);
380
+ return [consumer.name, perConsumer] as const;
381
+ }),
382
+ );
383
+ for (const [name, perConsumer] of results) {
384
+ byConsumer[name] = perConsumer;
328
385
  totalProcessed += perConsumer.processed;
329
386
  totalFailed += perConsumer.failed;
330
387
  }
@@ -376,20 +433,80 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
376
433
  // staying permanently suppressed by this process's Set.
377
434
  reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
378
435
 
379
- const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
380
- // skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
381
- // so an idle consumer doesn't burn a WAL record on every poll tick.
382
- if (events.length === 0) {
436
+ const oldCursor = acquired.state.lastProcessedEventId;
437
+ const pendingGaps = acquired.state.pendingGaps;
438
+ // xmin BEFORE the fetch: a range is provably burnt only once this has
439
+ // passed the xmax recorded with it. Only needed when ranges exist.
440
+ const xminNow = pendingGaps.length > 0 ? await selectSnapshotXmin(tx) : "0";
441
+ const events = await fetchPendingEvents(tx, oldCursor, batchSize, toIdRanges(pendingGaps));
442
+ const fetchedIds = events.map((e) => e.id);
443
+ const truncated = events.length === batchSize;
444
+
445
+ // Burnt: no visible id this turn, the fetch is proven to have
446
+ // covered the range (LIMIT can otherwise cut it off early), and
447
+ // xmin passed its recorded xmax — every xact that could still
448
+ // produce a row has finished without one ever appearing.
449
+ const { burnt: burntGaps, surviving: survivingGaps } = partitionBurntGaps(
450
+ pendingGaps,
451
+ fetchedIds,
452
+ truncated,
453
+ xminNow,
454
+ );
455
+
456
+ // skip: nothing to deliver and no burnt gap to clean up — no
457
+ // markProcessing/persistConsumerOutcome write, so an idle consumer
458
+ // doesn't burn a WAL record on every poll tick.
459
+ if (events.length === 0 && burntGaps.length === 0) {
383
460
  span.setAttribute("consumer.skip_reason", "no_pending_events");
384
461
  return;
385
462
  }
386
463
  await markProcessing(tx, consumer.name, instanceId);
387
464
 
388
- const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
465
+ const outcome: DeliveryOutcome =
466
+ events.length > 0
467
+ ? await deliverEvents(consumer, events, context, maxAttempts, acquired.state)
468
+ : {
469
+ cursor: oldCursor,
470
+ attempts: acquired.state.attempts,
471
+ lastError: acquired.state.lastError,
472
+ deadLettered: false,
473
+ processed: 0,
474
+ failed: 0,
475
+ resolvedPendingIds: [],
476
+ };
389
477
  processed = outcome.processed;
390
478
  failed = outcome.failed;
391
479
 
392
- await persistConsumerOutcome(tx, consumer.name, instanceId, outcome);
480
+ // Carve delivered/skip-applied ids out of the surviving ranges —
481
+ // an id still fetched-but-unresolved (halt-on-poison stopped before
482
+ // it) stays put, since it's below the cursor and only pending_gaps
483
+ // will ever retry it.
484
+ const keptGaps = survivingGaps.flatMap((gap) =>
485
+ splitRangeExcludingIds(gap, outcome.resolvedPendingIds),
486
+ );
487
+ // New gaps as ranges between consecutive fetched ids, so a huge id
488
+ // jump (retention prune) costs one entry, not one per missing id.
489
+ // Their xmax is read after the fetch; any later read is only more
490
+ // conservative, so it is fetched lazily.
491
+ const newWindowIds = fetchedIds.filter((id) => id > oldCursor && id <= outcome.cursor);
492
+ const newGapBounds: Array<readonly [bigint, bigint]> = [];
493
+ let prev = oldCursor;
494
+ for (const id of newWindowIds) {
495
+ if (id > prev + 1n) newGapBounds.push([prev + 1n, id - 1n]);
496
+ prev = id;
497
+ }
498
+ const xmaxNow = newGapBounds.length > 0 ? await selectSnapshotXmax(tx) : "";
499
+ const newGaps: PendingGapEntry[] = newGapBounds.map(([from, to]) => ({
500
+ from: from.toString(),
501
+ to: to.toString(),
502
+ xmax: xmaxNow,
503
+ }));
504
+ const persistedOutcome: PersistedConsumerOutcome = {
505
+ ...outcome,
506
+ pendingGaps: [...keptGaps, ...newGaps],
507
+ };
508
+
509
+ await persistConsumerOutcome(tx, consumer.name, instanceId, persistedOutcome);
393
510
  await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
394
511
  });
395
512
 
@@ -544,7 +661,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
544
661
  }
545
662
 
546
663
  // Drain any in-flight pass so shutdown observes consistent state.
547
- await drainPassInFlight();
664
+ await drainInFlightTurns();
548
665
  // preRegistered stays true — the rows survive stop(). runOnce()
549
666
  // after a stop() still works (tests stop the timer and then drain
550
667
  // deterministically).
@@ -555,7 +672,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
555
672
  preRegistered = true;
556
673
  },
557
674
 
558
- drain: drainPassInFlight,
675
+ drain: drainInFlightTurns,
559
676
 
560
677
  runOnce,
561
678
  };
@@ -19,6 +19,7 @@ export type IdempotencyGuard = {
19
19
  token: string,
20
20
  result: unknown,
21
21
  ): Promise<void>;
22
+ release(tenantId: string, userId: string, requestId: string, token: string): Promise<void>;
22
23
  };
23
24
 
24
25
  // Sentinel prefix stored under the key while the handler is running. Each
@@ -64,6 +65,16 @@ export function createIdempotencyGuard(
64
65
  end
65
66
  `;
66
67
 
68
+ // Same CAS guard as storeScript: only clear the lock if we still own it.
69
+ // A stale token (lock already reclaimed by a new owner) is a no-op.
70
+ const releaseScript = `
71
+ if redis.call("get", KEYS[1]) == ARGV[1] then
72
+ return redis.call("del", KEYS[1])
73
+ else
74
+ return 0
75
+ end
76
+ `;
77
+
67
78
  async function tryAcquire(key: string): Promise<string | null> {
68
79
  const token = `${PENDING_PREFIX}${generateId()}`;
69
80
  const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
@@ -138,5 +149,10 @@ export function createIdempotencyGuard(
138
149
  // SET and could stomp that fresher result with our stale one.
139
150
  void written;
140
151
  },
152
+
153
+ async release(tenantId, userId, requestId, token) {
154
+ const key = `${prefix}${tenantId}:${userId}:${requestId}`;
155
+ await redis.eval(releaseScript, 1, key, token);
156
+ },
141
157
  };
142
158
  }
@@ -0,0 +1,72 @@
1
+ import type { PendingIdRange } from "../db/queries/event-store";
2
+ import type { PendingGapEntry } from "./event-consumer-state";
3
+
4
+ export function toIdRanges(gaps: readonly PendingGapEntry[]): PendingIdRange[] {
5
+ return gaps.map((g) => ({ from: BigInt(g.from), to: BigInt(g.to) }));
6
+ }
7
+
8
+ export function rangeContainsId(range: PendingGapEntry, id: bigint): boolean {
9
+ return id >= BigInt(range.from) && id <= BigInt(range.to);
10
+ }
11
+
12
+ // A range is only provably burnt (rolled back, not just slow) once it's
13
+ // certain the fetch would have surfaced any of its ids had they been
14
+ // visible. LIMIT can cut a fetch off before reaching the range's ids —
15
+ // `to < maxFetchedId` proves the scan passed the range regardless of the
16
+ // cutoff, since ORDER BY id ASC LIMIT N always includes every matching id
17
+ // below the largest one it did return.
18
+ export function isRangeFullyCoveredByFetch(
19
+ range: PendingGapEntry,
20
+ truncated: boolean,
21
+ maxFetchedId: bigint | null,
22
+ ): boolean {
23
+ if (!truncated) return true;
24
+ if (maxFetchedId === null) return false;
25
+ return BigInt(range.to) < maxFetchedId;
26
+ }
27
+
28
+ // A gap is burnt when none of its ids showed up in this turn's fetch, the
29
+ // fetch is proven to have covered it, and its recorded xmax is behind this
30
+ // turn's xmin (every xact that could still produce a row has finished).
31
+ export function partitionBurntGaps(
32
+ gaps: readonly PendingGapEntry[],
33
+ fetchedIds: readonly bigint[],
34
+ truncated: boolean,
35
+ xminNow: string,
36
+ ): { readonly burnt: PendingGapEntry[]; readonly surviving: PendingGapEntry[] } {
37
+ const maxFetchedId = fetchedIds.at(-1) ?? null;
38
+ const burnt: PendingGapEntry[] = [];
39
+ const surviving: PendingGapEntry[] = [];
40
+ for (const gap of gaps) {
41
+ const hasVisibleId = fetchedIds.some((id) => rangeContainsId(gap, id));
42
+ const isBurnt =
43
+ !hasVisibleId &&
44
+ isRangeFullyCoveredByFetch(gap, truncated, maxFetchedId) &&
45
+ BigInt(gap.xmax) <= BigInt(xminNow);
46
+ (isBurnt ? burnt : surviving).push(gap);
47
+ }
48
+ return { burnt, surviving };
49
+ }
50
+
51
+ // Carves `excludeIds` (delivered or skip-applied this turn) out of `range`,
52
+ // yielding 0-2 sub-ranges. xmax is preserved on every surviving piece.
53
+ export function splitRangeExcludingIds(
54
+ range: PendingGapEntry,
55
+ excludeIds: readonly bigint[],
56
+ ): PendingGapEntry[] {
57
+ const from = BigInt(range.from);
58
+ const to = BigInt(range.to);
59
+ const inRange = excludeIds
60
+ .filter((id) => id >= from && id <= to)
61
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
62
+ if (inRange.length === 0) return [range];
63
+ const parts: PendingGapEntry[] = [];
64
+ let cursor = from;
65
+ for (const id of inRange) {
66
+ if (id > cursor)
67
+ parts.push({ from: cursor.toString(), to: (id - 1n).toString(), xmax: range.xmax });
68
+ cursor = id + 1n;
69
+ }
70
+ if (cursor <= to) parts.push({ from: cursor.toString(), to: to.toString(), xmax: range.xmax });
71
+ return parts;
72
+ }
@@ -490,6 +490,13 @@ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): strin
490
490
  return typeof userId === "string" && userId.length > 0 ? userId : undefined;
491
491
  }
492
492
 
493
+ // A "revoke all others" write must not cut the caller's own live stream, so
494
+ // session-revoked carries the spared sid. Missing or malformed → userwide.
495
+ function readSessionRevokedKeptSessionId(payload: Record<string, unknown>): string | undefined {
496
+ const keptSessionId = payload["keptSessionId"];
497
+ return typeof keptSessionId === "string" && keptSessionId.length > 0 ? keptSessionId : undefined;
498
+ }
499
+
493
500
  export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
494
501
  return {
495
502
  name: ACCESS_INVALIDATION_CONSUMER_NAME,
@@ -511,7 +518,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
511
518
  // poison would otherwise permanently stop access-invalidation for
512
519
  // every user behind one bad row).
513
520
  if (typeof userId !== "string" || userId.length === 0) return;
514
- sseBroker.publishAccessInvalidation(userId);
521
+ sseBroker.publishAccessInvalidation(userId, readSessionRevokedKeptSessionId(event.payload));
515
522
  }
516
523
 
517
524
  if (
@@ -1,4 +1,9 @@
1
- import { type TenantDb, withUnsafeRawGrant } from "../db/tenant-db";
1
+ import {
2
+ type TenantDb,
3
+ type UncheckedSystemDb,
4
+ withSystemDbUnsafeRawGrant,
5
+ withUnsafeRawGrant,
6
+ } from "../db/tenant-db";
2
7
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
3
8
  import type {
4
9
  ActiveMembershipResult,
@@ -214,7 +219,10 @@ function readIdentitySwitchFn<
214
219
  }
215
220
 
216
221
  // context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
217
- function readDbLikeValue(context: object, key: "db" | "dbOutsideTransaction"): object | undefined {
222
+ function readDbLikeValue(
223
+ context: object,
224
+ key: "db" | "dbOutsideTransaction" | "systemDb",
225
+ ): object | undefined {
218
226
  if (!(key in context)) return undefined;
219
227
  const value = (context as Record<string, unknown>)[key];
220
228
  return typeof value === "object" && value !== null ? value : undefined;
@@ -340,6 +348,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
340
348
  const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
341
349
  const ctxDb = readDbLikeValue(context, "db");
342
350
  const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
351
+ const ctxSystemDb = readDbLikeValue(context, "systemDb");
343
352
  if (
344
353
  !ctxQueryAs &&
345
354
  !ctxWriteAs &&
@@ -347,7 +356,8 @@ export function withHookEscapeHatchGrant<TContext extends object>(
347
356
  !ctxQueryAsMember &&
348
357
  !ctxQueryProjection &&
349
358
  !ctxDb &&
350
- !ctxDbOutsideTransaction
359
+ !ctxDbOutsideTransaction &&
360
+ !ctxSystemDb
351
361
  ) {
352
362
  return context;
353
363
  }
@@ -366,10 +376,18 @@ export function withHookEscapeHatchGrant<TContext extends object>(
366
376
  ...(ctxDbOutsideTransaction && {
367
377
  dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
368
378
  }),
379
+ // @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
380
+ ...(ctxSystemDb && {
381
+ systemDb: withSystemDbUnsafeRawGrant(
382
+ ctxSystemDb as UncheckedSystemDb,
383
+ escapeHatch,
384
+ callerLabel,
385
+ ),
386
+ }),
369
387
  };
370
388
  }
371
389
 
372
- // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
390
+ // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction/ctx.systemDb instead of inheriting the handler's grant.
373
391
  export function bindHookEscapeHatchGrant(
374
392
  fn: LifecycleHookFn,
375
393
  label: string,
@@ -1,6 +1,8 @@
1
1
  // The static check in boot-validator/access-declarations.ts only sees a handler's own
2
2
  // input schema; writes reached via ctx.write/writeAs/queryAs, hooks or foreign-feature
3
3
  // tables are only visible at the actual write, so the gate runs there at runtime.
4
+ import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
5
+ import { z } from "zod";
4
6
  import { buildEntityTable } from "../db/table-builder";
5
7
  import { type PersonalDataGate, tableNameOf } from "../db/tenant-db";
6
8
  import {
@@ -15,12 +17,29 @@ import { AccessDeniedError } from "../errors";
15
17
  import { FrameworkReasons } from "../errors/reasons";
16
18
  import { toSnakeCase } from "../utils/case";
17
19
 
18
- export type WriteOrigin = {
19
- readonly rootHandler: string;
20
- readonly anonymousRoot: boolean;
21
- // Only a write-handler root can declare public-intake; a query/stream root never does.
22
- readonly publicIntake: boolean;
23
- };
20
+ export { isPersonalDataGated, type WriteOrigin };
21
+
22
+ // Only narrows: an inherited origin can add a gate, never lift the root's.
23
+ export function effectiveWriteOrigin(root: WriteOrigin, inherited?: WriteOrigin): WriteOrigin {
24
+ if (!inherited) return root;
25
+ if (isPersonalDataGated(root)) return root;
26
+ if (isPersonalDataGated(inherited)) return inherited;
27
+ return root;
28
+ }
29
+
30
+ const writeOriginSchema = z
31
+ .object({
32
+ rootHandler: z.string(),
33
+ anonymousRoot: z.boolean(),
34
+ publicIntake: z.boolean(),
35
+ viaJob: z.string().optional(),
36
+ })
37
+ .strict();
38
+
39
+ export function parseWriteOrigin(value: unknown): WriteOrigin | undefined {
40
+ const parsed = writeOriginSchema.safeParse(value);
41
+ return parsed.success ? parsed.data : undefined;
42
+ }
24
43
 
25
44
  function declaresPublicIntake(access: AccessRule): boolean {
26
45
  return accessAllowsAnonymous(access) && declaredPersonalData(access) === "public-intake";
@@ -67,7 +86,7 @@ function personalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlyS
67
86
  }
68
87
 
69
88
  // Names fields only, never values: the error reaches the anonymous HTTP caller.
70
- function publicIntakeRequiredError(
89
+ export function publicIntakeRequiredError(
71
90
  origin: WriteOrigin,
72
91
  target: string,
73
92
  fields: readonly string[],
@@ -75,14 +94,16 @@ function publicIntakeRequiredError(
75
94
  return new AccessDeniedError({
76
95
  message:
77
96
  `Anonymous root handler "${origin.rootHandler}" wrote personal-data field(s) ` +
78
- `${fields.map((f) => `"${f}"`).join(", ")} on "${target}". Declare ` +
79
- 'access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
97
+ `${fields.map((f) => `"${f}"`).join(", ")} on "${target}"` +
98
+ (origin.viaJob ? ` via job "${origin.viaJob}"` : "") +
99
+ '. Declare access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
80
100
  `"${origin.rootHandler}" to allow anonymous callers to write personal data.`,
81
101
  details: {
82
102
  reason: FrameworkReasons.publicIntakeRequired,
83
103
  rootHandler: origin.rootHandler,
84
104
  target,
85
105
  fields,
106
+ ...(origin.viaJob !== undefined && { job: origin.viaJob }),
86
107
  },
87
108
  });
88
109
  }
@@ -93,7 +114,7 @@ export function buildPersonalDataGate(
93
114
  registry: Registry,
94
115
  origin: WriteOrigin,
95
116
  ): PersonalDataGate | undefined {
96
- if (!origin.anonymousRoot || origin.publicIntake) return undefined;
117
+ if (!isPersonalDataGated(origin)) return undefined;
97
118
  const map = personalDataTableMap(registry);
98
119
  return (tableName, keys, entity) => {
99
120
  const personalFields = entity ? personalColumnNames(entity) : map.get(tableName);
@@ -470,7 +470,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
470
470
 
471
471
  // Pre-register consumer state rows so tests can call runOnce() directly
472
472
  // without a preceding explicit start(). Timer fires at pollIntervalMs=50
473
- // but passInFlight serialises concurrent passes — tests that drain via
473
+ // but each consumer runs at most one turn at a time — tests that drain via
474
474
  // runOnce() remain deterministic. Tests that specifically exercise the
475
475
  // timer loop call start() again (idempotent) after setup.
476
476
  if (eventDispatcher) await eventDispatcher.ensureRegistered();
@@ -0,0 +1,62 @@
1
+ // Captures a real closed-connection error instead of a hand-built fake —
2
+ // the production matcher checks driver-specific codes a fake can't reproduce.
3
+
4
+ import postgres from "postgres";
5
+ import { isClosedConnectionError } from "../bun-db/query";
6
+
7
+ export function testDatabaseUrl(): string {
8
+ return (
9
+ process.env["TEST_DATABASE_URL"] ??
10
+ process.env["DATABASE_URL"] ??
11
+ "postgresql://kumiko:kumiko@localhost:15432/kumiko_test"
12
+ );
13
+ }
14
+
15
+ const MAX_WARMUP_ROUNDS = 10;
16
+ const READS_PER_ROUND = 5;
17
+
18
+ type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
19
+
20
+ // A per-round admin client hits a postgres-js reconnect timing bug (the first
21
+ // retry after terminate hangs); a single long-lived admin client avoids it.
22
+ export async function terminateBackendsByApplicationName(
23
+ admin: AdminClient,
24
+ applicationName: string,
25
+ ): Promise<void> {
26
+ await admin.unsafe(
27
+ "select pg_terminate_backend(pid) from pg_stat_activity where application_name = $1",
28
+ [applicationName],
29
+ );
30
+ }
31
+
32
+ // Terminates a throwaway pool's backends and races reads right after — the
33
+ // dead-connection window is only a few ms wide, so this retries rounds until one lands.
34
+ export async function captureClosedConnectionError(
35
+ url: string = testDatabaseUrl(),
36
+ ): Promise<unknown> {
37
+ const admin = postgres(url, { max: 1 });
38
+ try {
39
+ for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
40
+ const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
41
+ const pool = postgres(url, { max: 1, connection: { application_name: applicationName } });
42
+ try {
43
+ await pool.unsafe("select 1");
44
+ await terminateBackendsByApplicationName(admin, applicationName);
45
+ for (let read = 0; read < READS_PER_ROUND; read++) {
46
+ try {
47
+ await pool.unsafe("select 1");
48
+ } catch (err) {
49
+ if (isClosedConnectionError(err)) return err;
50
+ }
51
+ }
52
+ } finally {
53
+ await pool.end({ timeout: 0 });
54
+ }
55
+ }
56
+ throw new Error(
57
+ `captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
58
+ );
59
+ } finally {
60
+ await admin.end({ timeout: 0 });
61
+ }
62
+ }
@@ -15,6 +15,7 @@ export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encr
15
15
  export { rolesOf } from "./access-assertions";
16
16
  export { expectError, expectSuccess } from "./assertions";
17
17
  export { withBootValidatorFixture } from "./boot-validator-fixture";
18
+ export { captureClosedConnectionError } from "./closed-connection-error";
18
19
  export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup";
19
20
  export {
20
21
  type E2EGeneratorOptions,