@cosmicdrift/kumiko-framework 0.306.0 → 0.308.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 (178) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  3. package/src/__tests__/error-contract.integration.test.ts +1 -1
  4. package/src/__tests__/field-access.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +1 -1
  6. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +1 -1
  7. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +1 -1
  8. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +1 -1
  9. package/src/__tests__/full-stack.integration.test.ts +1 -1
  10. package/src/__tests__/transition-guard.integration.test.ts +1 -1
  11. package/src/api/__tests__/api.test.ts +1 -1
  12. package/src/api/__tests__/batch.integration.test.ts +1 -1
  13. package/src/api/__tests__/body-limit.test.ts +1 -1
  14. package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
  15. package/src/api/__tests__/extra-routes.integration.test.ts +1 -1
  16. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +1 -1
  17. package/src/api/__tests__/nested-write-ownership.integration.test.ts +1 -1
  18. package/src/api/__tests__/nested-write.integration.test.ts +1 -1
  19. package/src/api/__tests__/pii-leak-guard.integration.test.ts +1 -1
  20. package/src/api/__tests__/redis-sse-broker.integration.test.ts +133 -0
  21. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  22. package/src/api/__tests__/server-boot-guards.test.ts +1 -1
  23. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  24. package/src/api/__tests__/sse-broker.test.ts +144 -1
  25. package/src/api/__tests__/stream-escape-hatch.integration.test.ts +1 -1
  26. package/src/api/auth-routes.ts +1 -1
  27. package/src/api/redis-sse-broker.ts +57 -3
  28. package/src/api/request-context.ts +24 -0
  29. package/src/api/server.ts +11 -0
  30. package/src/api/sse-broker.ts +69 -12
  31. package/src/changes.json +63 -0
  32. package/src/compliance/override-schema.ts +1 -1
  33. package/src/crypto/__tests__/event-pii.test.ts +1 -1
  34. package/src/crypto/kms-adapter.ts +1 -1
  35. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  36. package/src/db/queries/event-consumer.ts +155 -9
  37. package/src/db/queries/event-store.ts +69 -0
  38. package/src/db/tenant-db.ts +43 -5
  39. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +1 -1
  40. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +1 -1
  41. package/src/engine/__tests__/boot-validator-dashboard.test.ts +1 -1
  42. package/src/engine/__tests__/boot-validator-nav-area.test.ts +1 -1
  43. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  44. package/src/engine/__tests__/boot-validator-projection-list.test.ts +1 -1
  45. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +1 -1
  46. package/src/engine/__tests__/boot-validator-query-refs.test.ts +1 -1
  47. package/src/engine/__tests__/boot-validator-ref-entity.test.ts +1 -1
  48. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +1 -1
  49. package/src/engine/__tests__/boot-validator.test.ts +35 -1
  50. package/src/engine/__tests__/build-app-schema.test.ts +1 -1
  51. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +1 -1
  52. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +1 -1
  53. package/src/engine/__tests__/engine.test.ts +1 -1
  54. package/src/engine/__tests__/event-helpers.test.ts +1 -1
  55. package/src/engine/__tests__/event-migration-declarative.test.ts +1 -1
  56. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  57. package/src/engine/__tests__/extends-registrar.test.ts +1 -1
  58. package/src/engine/__tests__/hook-phases.test.ts +1 -1
  59. package/src/engine/__tests__/lifecycle-hooks.test.ts +1 -1
  60. package/src/engine/__tests__/pipeline-engine.test.ts +1 -1
  61. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  62. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  63. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  64. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +1 -1
  65. package/src/engine/__tests__/post-query-hook.test.ts +1 -1
  66. package/src/engine/__tests__/projection-detail-actions.test.ts +1 -1
  67. package/src/engine/__tests__/projection-detail-singleton.test.ts +1 -1
  68. package/src/engine/__tests__/projection-detail-tabs.test.ts +1 -1
  69. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +1 -1
  70. package/src/engine/__tests__/registry.test.ts +1 -1
  71. package/src/engine/__tests__/screen.test.ts +1 -1
  72. package/src/engine/__tests__/validate-projection-allowlist.test.ts +1 -1
  73. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +1 -1
  74. package/src/engine/boot-validator/__tests__/action-icon.test.ts +81 -0
  75. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +1 -1
  76. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +1 -1
  77. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +1 -1
  78. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +1 -1
  79. package/src/engine/boot-validator/screens.ts +83 -0
  80. package/src/engine/define-handler.ts +2 -1
  81. package/src/engine/entity-handlers.ts +2 -1
  82. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +1 -1
  83. package/src/engine/feature-ast/pattern-change-schema.ts +1 -1
  84. package/src/engine/feature-builder-state.ts +2 -1
  85. package/src/engine/feature-config-events-jobs.ts +2 -1
  86. package/src/engine/feature-entity-handlers.ts +2 -1
  87. package/src/engine/schema-builder.ts +1 -1
  88. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  89. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -1
  90. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  91. package/src/env/__tests__/compose-env-schema.test.ts +1 -1
  92. package/src/env/__tests__/dry-run.test.ts +1 -1
  93. package/src/env/__tests__/parse-env-dry-run.test.ts +1 -1
  94. package/src/env/_zod-introspect.ts +1 -1
  95. package/src/env/dry-run.ts +1 -1
  96. package/src/env/index.ts +1 -1
  97. package/src/errors/__tests__/classes.test.ts +1 -1
  98. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  99. package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
  100. package/src/event-store/__tests__/event-attribution.integration.test.ts +54 -4
  101. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  102. package/src/event-store/admin-api.ts +5 -0
  103. package/src/event-store/event-store.ts +16 -7
  104. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -1
  105. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +1 -1
  106. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +1 -1
  107. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  108. package/src/jobs/__tests__/job-retention.integration.test.ts +1 -1
  109. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -1
  110. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  111. package/src/jobs/job-runner.ts +61 -6
  112. package/src/observability/__tests__/metrics-wiring.test.ts +1 -1
  113. package/src/observability/__tests__/observability.integration.test.ts +1 -1
  114. package/src/observability/metrics-wiring.ts +1 -1
  115. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  117. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +1 -1
  118. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +1 -1
  119. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  120. package/src/pipeline/__tests__/dispatcher.test.ts +1 -1
  121. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +1 -1
  122. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +1 -1
  123. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +1 -1
  124. package/src/pipeline/__tests__/event-dedup.integration.test.ts +1 -1
  125. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/event-dispatcher-batch-delivery.test.ts +178 -0
  127. package/src/pipeline/__tests__/event-dispatcher-batch-handler.integration.test.ts +143 -0
  128. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  129. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  130. package/src/pipeline/__tests__/event-dispatcher-idle.integration.test.ts +94 -0
  131. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  132. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  133. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +103 -3
  134. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  135. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +1 -1
  136. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +1 -1
  137. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +1 -1
  138. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  139. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  140. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +116 -1
  141. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +1 -1
  142. package/src/pipeline/__tests__/pending-gap-ranges.test.ts +94 -0
  143. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +1 -1
  144. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +262 -3
  145. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  146. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +1 -1
  147. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -1
  148. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +1 -1
  149. package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +1 -1
  150. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  151. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +1 -1
  152. package/src/pipeline/dispatch-batch.ts +49 -19
  153. package/src/pipeline/dispatch-stream.ts +12 -3
  154. package/src/pipeline/dispatcher-utils.ts +21 -2
  155. package/src/pipeline/dispatcher.ts +71 -6
  156. package/src/pipeline/event-consumer-state.ts +26 -0
  157. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  158. package/src/pipeline/event-dispatcher-delivery.ts +212 -65
  159. package/src/pipeline/event-dispatcher.ts +253 -49
  160. package/src/pipeline/index.ts +1 -0
  161. package/src/pipeline/msp-rebuild.ts +39 -1
  162. package/src/pipeline/pending-gap-ranges.ts +120 -0
  163. package/src/pipeline/system-hooks.ts +226 -64
  164. package/src/pipeline/write-origin.ts +31 -10
  165. package/src/rate-limit/__tests__/dispatcher-l3.integration.test.ts +1 -1
  166. package/src/search/__tests__/derive-search-adapter-config.test.ts +65 -0
  167. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +91 -0
  168. package/src/search/__tests__/search-consumer-batch.integration.test.ts +306 -0
  169. package/src/search/derive-search-adapter-config.ts +27 -0
  170. package/src/search/index.ts +1 -0
  171. package/src/search/meilisearch-adapter.ts +96 -14
  172. package/src/secrets/__tests__/contains-secret.test.ts +1 -1
  173. package/src/stack/__tests__/request-helper.integration.test.ts +1 -1
  174. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  175. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +1 -1
  176. package/src/stack/test-stack.ts +1 -1
  177. package/src/testing/__tests__/e2e-generator.test.ts +1 -1
  178. package/src/testing/e2e-generator.ts +1 -1
@@ -4,7 +4,7 @@
4
4
  // escape-hatch.integration.test.ts (fw#2855).
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
- import { z } from "zod";
7
+ import * as z from "zod";
8
8
  import { executeRawQuery } from "../../db/queries/raw-sql";
9
9
  import { defineFeature } from "../../engine";
10
10
  import { createTestUser, setupTestStack, type TestStack } from "../../stack";
@@ -1,4 +1,5 @@
1
- import { requestContext } from "../api/request-context";
1
+ import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
2
+ import { requestContext, runWithWriteOrigin } from "../api/request-context";
2
3
  import type { DbConnection } from "../db/connection";
3
4
  import { transaction } from "../db/query";
4
5
  import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
@@ -14,7 +15,20 @@ import {
14
15
  isLifecycleResult,
15
16
  wrapToKumiko,
16
17
  } from "./dispatcher-utils";
17
- import { rootWriteOrigin } from "./write-origin";
18
+ import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin";
19
+
20
+ // afterCommit hooks fire in flushAfterCommit, outside the command's scope.
21
+ function rewrapHooksWithOrigin(
22
+ afterCommitHooks: AfterCommitHook[],
23
+ fromIndex: number,
24
+ origin: WriteOrigin,
25
+ ): void {
26
+ for (let i = fromIndex; i < afterCommitHooks.length; i++) {
27
+ const original = afterCommitHooks[i];
28
+ if (!original) continue;
29
+ afterCommitHooks[i] = () => runWithWriteOrigin(origin, original);
30
+ }
31
+ }
18
32
 
19
33
  // Core batch logic extracted so write() and command() can reuse it
20
34
  // (a single write = batch of one, running in its own transaction).
@@ -23,15 +37,18 @@ export async function runBatch(
23
37
  commands: readonly BatchCommand[],
24
38
  user: SessionUser,
25
39
  requestId?: string,
40
+ inheritedOrigin?: WriteOrigin,
26
41
  ): Promise<BatchResult> {
27
42
  const current = requestContext.get();
28
43
  if (!current?.signal) {
29
- return runBatchBody(ctx, commands, user, requestId);
44
+ return runBatchBody(ctx, commands, user, requestId, inheritedOrigin);
30
45
  }
31
46
  // Strip the signal: a disconnect would roll back the tx, idempotency would
32
47
  // cache a 500 for the uncommitted write and afterCommit effects would be lost.
33
48
  const { signal: _signal, ...withoutSignal } = current;
34
- return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId));
49
+ return requestContext.run(withoutSignal, () =>
50
+ runBatchBody(ctx, commands, user, requestId, inheritedOrigin),
51
+ );
35
52
  }
36
53
 
37
54
  async function runBatchBody(
@@ -39,6 +56,7 @@ async function runBatchBody(
39
56
  commands: readonly BatchCommand[],
40
57
  user: SessionUser,
41
58
  requestId?: string,
59
+ inheritedOrigin?: WriteOrigin,
42
60
  ): Promise<BatchResult> {
43
61
  const { idempotency, lifecycle, appContext: context } = ctx;
44
62
  if (commands.length === 0) {
@@ -109,10 +127,12 @@ async function runBatchBody(
109
127
  }
110
128
  };
111
129
 
130
+ const origins: WriteOrigin[] = [];
131
+
112
132
  // Fires the batch-level system hooks with every successful save/delete
113
133
  // context from this run. Called after flushAfterCommit so per-save hooks
114
134
  // have all completed first; errors are isolated inside lifecycleHooks.
115
- const flushBatchHooks = async () => {
135
+ const flushBatchHooksInner = async () => {
116
136
  try {
117
137
  const saves: SaveContext[] = [];
118
138
  const deletes: DeleteContext[] = [];
@@ -133,6 +153,14 @@ async function runBatchBody(
133
153
  }
134
154
  };
135
155
 
156
+ // Batch hooks see every command's saves, so they run under the strictest origin.
157
+ const flushBatchHooks = async () => {
158
+ const strictest = origins.find(isPersonalDataGated) ?? origins[0];
159
+ // skip: no command ran, so batch hooks have no saves or deletes to see
160
+ if (!strictest) return;
161
+ await runWithWriteOrigin(strictest, flushBatchHooksInner);
162
+ };
163
+
136
164
  // batch() opens its own outer transaction — needs the top-level
137
165
  // connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
138
166
  const db = resolveDbSource(ctx, undefined) as DbConnection | undefined;
@@ -143,15 +171,16 @@ async function runBatchBody(
143
171
  for (let i = 0; i < commands.length; i++) {
144
172
  const cmd = commands[i];
145
173
  if (!cmd) continue;
146
- const res = await executeNestedWrite(
147
- ctx,
148
- cmd.type,
149
- cmd.payload,
150
- user,
174
+ const origin = effectiveWriteOrigin(
151
175
  rootWriteOrigin(ctx.registry, cmd.type, user),
152
- undefined,
153
- afterCommitHooks,
176
+ inheritedOrigin,
154
177
  );
178
+ origins.push(origin);
179
+ const hookStart = afterCommitHooks.length;
180
+ const res = await runWithWriteOrigin(origin, () =>
181
+ executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, undefined, afterCommitHooks),
182
+ );
183
+ rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
155
184
  results.push(res);
156
185
  if (!res.isSuccess) {
157
186
  // No tx means no rollback — but we still drop afterCommit hooks,
@@ -170,15 +199,16 @@ async function runBatchBody(
170
199
  for (let i = 0; i < commands.length; i++) {
171
200
  const cmd = commands[i];
172
201
  if (!cmd) continue;
173
- const res = await executeNestedWrite(
174
- ctx,
175
- cmd.type,
176
- cmd.payload,
177
- user,
202
+ const origin = effectiveWriteOrigin(
178
203
  rootWriteOrigin(ctx.registry, cmd.type, user),
179
- tx,
180
- afterCommitHooks,
204
+ inheritedOrigin,
205
+ );
206
+ origins.push(origin);
207
+ const hookStart = afterCommitHooks.length;
208
+ const res = await runWithWriteOrigin(origin, () =>
209
+ executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks),
181
210
  );
211
+ rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
182
212
  results.push(res);
183
213
  if (!res.isSuccess) {
184
214
  throw new BatchRollback(i, res.error);
@@ -1,3 +1,4 @@
1
+ import type { AccessInvalidationCredential } from "../api/sse-broker";
1
2
  import { hasAccess } from "../engine/access";
2
3
  import type { SessionUser } from "../engine/types";
3
4
  import {
@@ -74,9 +75,17 @@ async function* executeStreamInner(
74
75
  const invalidated = new Promise<void>((resolve) => {
75
76
  resolveInvalidated = resolve;
76
77
  });
77
- const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
78
- resolveInvalidated?.();
79
- });
78
+ const streamCredential: AccessInvalidationCredential = {
79
+ ...(user.sid !== undefined && { sid: user.sid }),
80
+ ...(user.pat?.tokenId !== undefined && { patTokenId: user.pat.tokenId }),
81
+ };
82
+ const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(
83
+ user.id,
84
+ () => {
85
+ resolveInvalidated?.();
86
+ },
87
+ streamCredential,
88
+ );
80
89
 
81
90
  let iterator: AsyncIterator<unknown> | undefined;
82
91
  // When access is revoked mid-pull, `iterator.next()` is still in flight.
@@ -6,7 +6,14 @@ import type {
6
6
  SessionUser,
7
7
  WriteResult,
8
8
  } from "../engine/types";
9
- import { type FieldIssue, toKumikoError, type WriteErrorInfo } from "../errors";
9
+ import {
10
+ type FieldIssue,
11
+ type KumikoError,
12
+ toKumikoError,
13
+ VersionConflictError,
14
+ type WriteErrorInfo,
15
+ } from "../errors";
16
+ import { VersionConflictError as EventStoreVersionConflictError } from "../event-store/errors";
10
17
 
11
18
  export type FailedWriteResult = Extract<WriteResult, { isSuccess: false }>;
12
19
 
@@ -177,4 +184,16 @@ export function resolveType(type: HandlerType): string {
177
184
  return typeof type === "string" ? type : type.name;
178
185
  }
179
186
 
180
- export const wrapToKumiko = toKumikoError;
187
+ // A custom write losing an append race (ctx.appendEvent, stream.append) is a
188
+ // retryable 409 like the CRUD executor's, not an internal error. Mapped here
189
+ // because errors/ is client code and must not import the event store.
190
+ // currentVersion -1 is the executor's "not looked up" sentinel.
191
+ export function wrapToKumiko(e: unknown): KumikoError {
192
+ if (e instanceof EventStoreVersionConflictError) {
193
+ return new VersionConflictError(
194
+ { entityId: e.aggregateId, expectedVersion: e.expectedVersion, currentVersion: -1 },
195
+ { cause: e },
196
+ );
197
+ }
198
+ return toKumikoError(e);
199
+ }
@@ -1,3 +1,5 @@
1
+ import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
2
+ import { runWithWriteOrigin } from "../api/request-context";
1
3
  import type { SseBroker } from "../api/sse-broker";
2
4
  import type { buildEntityTable } from "../db/table-builder";
3
5
  import {
@@ -18,7 +20,7 @@ import type {
18
20
  WriteResult,
19
21
  } from "../engine/types";
20
22
  import type { TenantId } from "../engine/types/identifiers";
21
- import { reraiseAsKumikoError } from "../errors";
23
+ import { InternalError, reraiseAsKumikoError } from "../errors";
22
24
  import { getFallbackMeter, getFallbackTracer, registerStandardMetrics } from "../observability";
23
25
  import { createEscapeHatchReportWindow } from "../observability/escape-hatch-report";
24
26
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
@@ -32,7 +34,7 @@ import type { IdempotencyGuard } from "./idempotency";
32
34
  import type { LifecycleHooks } from "./lifecycle-pipeline";
33
35
  import { createMemberReaderFn } from "./member-reader";
34
36
  import { createTenantTimezoneCache } from "./tenant-timezone-cache";
35
- import { rootWriteOrigin } from "./write-origin";
37
+ import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin";
36
38
 
37
39
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
38
40
  // HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
@@ -111,12 +113,55 @@ export type Dispatcher = {
111
113
  createMemberReader(tenantId: TenantId): MemberReader;
112
114
  };
113
115
 
116
+ // Kept off the public Dispatcher type: only a job may pass an inherited origin.
117
+ type DispatcherInternals = {
118
+ writeWithOrigin: (
119
+ type: string,
120
+ payload: unknown,
121
+ user: SessionUser,
122
+ inheritedOrigin: WriteOrigin,
123
+ ) => Promise<WriteResult>;
124
+ queryWithOrigin: (
125
+ type: string,
126
+ payload: unknown,
127
+ user: SessionUser,
128
+ inheritedOrigin: WriteOrigin,
129
+ ) => Promise<unknown>;
130
+ };
131
+
132
+ const dispatcherInternals = new WeakMap<Dispatcher, DispatcherInternals>();
133
+
114
134
  // Adapts Dispatcher's (type, payload, user) call shape to DispatchWriteRef's
115
135
  // (user, qn, payload) — JobRunner.attachDispatcher needs the latter.
116
136
  export function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
137
+ const internals = dispatcherInternals.get(dispatcher);
117
138
  return {
118
- write: (user, qn, payload) => dispatcher.write(qn, payload, user),
119
- queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
139
+ write: (user, qn, payload, inheritedOrigin) => {
140
+ if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
141
+ if (!internals) {
142
+ throw new InternalError({
143
+ message:
144
+ `JobContext.write("${qn}") carries a gated origin but this dispatcher has no ` +
145
+ "registered origin-aware internals — refusing to fall open to the ungated path.",
146
+ });
147
+ }
148
+ return internals.writeWithOrigin(qn, payload, user, inheritedOrigin);
149
+ }
150
+ return dispatcher.write(qn, payload, user);
151
+ },
152
+ queryAs: (user, qn, payload, inheritedOrigin) => {
153
+ if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
154
+ if (!internals) {
155
+ throw new InternalError({
156
+ message:
157
+ `JobContext.queryAs("${qn}") carries a gated origin but this dispatcher has no ` +
158
+ "registered origin-aware internals — refusing to fall open to the ungated path.",
159
+ });
160
+ }
161
+ return internals.queryWithOrigin(qn, payload, user, inheritedOrigin);
162
+ }
163
+ return dispatcher.query(qn, payload, user);
164
+ },
120
165
  createMemberReader: (tenantId) => dispatcher.createMemberReader(tenantId),
121
166
  };
122
167
  }
@@ -173,7 +218,7 @@ export function createDispatcher(
173
218
  membershipQuery,
174
219
  };
175
220
 
176
- return {
221
+ const dispatcher: Dispatcher = {
177
222
  async write(typeOrRef, payload, user, requestId?) {
178
223
  const type = resolveType(typeOrRef);
179
224
  // Idempotency handled inside runBatch (caches BatchResult under requestId).
@@ -185,7 +230,8 @@ export function createDispatcher(
185
230
 
186
231
  query: (typeOrRef, payload, user) => {
187
232
  const type = resolveType(typeOrRef);
188
- return executeQuery(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
233
+ const origin = rootWriteOrigin(registry, type, user);
234
+ return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
189
235
  },
190
236
 
191
237
  stream: (typeOrRef, payload, user) => {
@@ -210,4 +256,23 @@ export function createDispatcher(
210
256
 
211
257
  createMemberReader: (tenantId) => createMemberReaderFn(ctx, tenantId),
212
258
  };
259
+
260
+ dispatcherInternals.set(dispatcher, {
261
+ writeWithOrigin: async (type, payload, user, inheritedOrigin) => {
262
+ const batchResult = await runBatch(
263
+ ctx,
264
+ [{ type, payload }],
265
+ user,
266
+ undefined,
267
+ inheritedOrigin,
268
+ );
269
+ return unwrapSingle(batchResult);
270
+ },
271
+ queryWithOrigin: (type, payload, user, inheritedOrigin) => {
272
+ const origin = effectiveWriteOrigin(rootWriteOrigin(registry, type, user), inheritedOrigin);
273
+ return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
274
+ },
275
+ });
276
+
277
+ return dispatcher;
213
278
  }
@@ -5,6 +5,7 @@ import {
5
5
  index,
6
6
  instant,
7
7
  integer,
8
+ jsonb,
8
9
  table as pgTable,
9
10
  primaryKey,
10
11
  sql,
@@ -64,6 +65,21 @@ export const SHARED_INSTANCE_SENTINEL = "__shared__";
64
65
  // The default(sql`0`) on lastProcessedEventId mirrors projection-state.ts:
65
66
  // drizzle-kit's JSON snapshot generator can't serialise a bigint literal, so
66
67
  // the server-side default is specified as raw SQL instead of .default(0n).
68
+
69
+ // A contiguous run of ids below the cursor invisible on some earlier turn,
70
+ // not yet resolved (delivered) or proven burnt (see event-dispatcher.ts's
71
+ // processConsumer). Ranges, not individual ids — a large historical gap
72
+ // (retention prune, or a new consumer starting "beginning" over pruned
73
+ // history) then costs O(1) entries instead of one per missing id. xmax is
74
+ // the pg_snapshot_xmax() captured when the range was recorded: once a later
75
+ // turn's xmin passes it, every xact that could still produce a row in this
76
+ // range has finished, so it's provably rolled back, not just slow.
77
+ // bigint/xid8 travel as strings — JS bigint doesn't round-trip through jsonb.
78
+ export type PendingGapEntry = {
79
+ readonly from: string;
80
+ readonly to: string;
81
+ readonly xmax: string;
82
+ };
67
83
  export const eventConsumerStateTable = pgTable(
68
84
  "kumiko_event_consumers",
69
85
  {
@@ -82,6 +98,7 @@ export const eventConsumerStateTable = pgTable(
82
98
  // poisoned), as does a manual restartConsumer()/enableConsumer()/
83
99
  // skipPoisonEvent() — an operator vouching the consumer is healthy.
84
100
  rearmCount: integer("rearm_count").notNull().default(0),
101
+ pendingGaps: jsonb("pending_gaps").$type<PendingGapEntry[]>().default([]).notNull(),
85
102
  lastError: text("last_error"),
86
103
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
87
104
  },
@@ -142,6 +159,15 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
142
159
  " NOT NULL",
143
160
  /* ifNotExists */ true,
144
161
  );
162
+ await alterTableAddColumn(
163
+ db,
164
+ "kumiko_event_consumers",
165
+ "pending_gaps",
166
+ "jsonb",
167
+ " DEFAULT '[]'::jsonb",
168
+ " NOT NULL",
169
+ /* ifNotExists */ true,
170
+ );
145
171
  // Runs on every boot, including mid-rolling-deploy: a new pod can delete
146
172
  // an old pod's still-in-use per-instance row out from under it. That old
147
173
  // pod's next acquireConsumer then finds no row, delivers no more SSE/
@@ -1,13 +1,15 @@
1
1
  import type { DbConnection, DbTx } from "../db/connection";
2
2
  import {
3
3
  advanceConsumerPastEventReturning,
4
+ removePendingGapReturning,
4
5
  updateConsumerStatusReturning,
5
6
  } from "../db/queries/event-consumer";
6
- import { selectNextEventIdAfter } from "../db/queries/event-store";
7
+ import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges } from "../db/queries/event-store";
7
8
  import { coerceRow, extractTableInfo, selectMany } from "../db/query";
8
9
  import { getEventsHighWaterMark } from "../event-store";
9
10
  import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL } from "./event-consumer-state";
10
11
  import type { ConsumerStateRow, ConsumerStateRowShape } from "./event-dispatcher-delivery";
12
+ import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges";
11
13
 
12
14
  // --- Ops recovery surface ---
13
15
  //
@@ -129,10 +131,13 @@ export async function enableConsumer(
129
131
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
130
132
  }
131
133
 
132
- // skipPoisonEvent advances the cursor past the first event after the
133
- // current cursor. Single TX so concurrent dispatcher passes can't double-
134
- // advance. If no event exists past the cursor, there is nothing to skip —
135
- // treat as idempotent no-op (cursor already at head).
134
+ // skipPoisonEvent advances past whatever is currently blocking the consumer.
135
+ // If the smallest visible id inside pending_gaps exists, THAT'S the poison —
136
+ // halt-on-poison walks gap ids before any id past the cursor — so it's split
137
+ // out of its range directly; the cursor stays put (it's already above this
138
+ // id). Otherwise the poison is the usual next event after the cursor. Single
139
+ // TX so concurrent dispatcher passes can't double-advance. Neither exists →
140
+ // idempotent no-op.
136
141
  export async function skipPoisonEvent(
137
142
  db: DbConnection,
138
143
  name: string,
@@ -140,6 +145,28 @@ export async function skipPoisonEvent(
140
145
  ): Promise<ConsumerRecoveryState & { readonly skippedEventId: bigint | null }> {
141
146
  const before = await requireConsumerRow(db, name, instanceId);
142
147
  return db.begin(async (tx: DbTx) => {
148
+ const pendingGaps = before.pendingGaps;
149
+ const smallestVisiblePending = await selectSmallestVisibleIdInRanges(
150
+ tx,
151
+ toIdRanges(pendingGaps),
152
+ );
153
+
154
+ if (smallestVisiblePending !== null) {
155
+ const newPendingGaps = pendingGaps.flatMap((gap) =>
156
+ rangeContainsId(gap, smallestVisiblePending)
157
+ ? splitRangeExcludingIds(gap, [smallestVisiblePending])
158
+ : [gap],
159
+ );
160
+ const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
161
+ const updated =
162
+ raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
163
+ if (!updated)
164
+ throw new Error(
165
+ `Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`,
166
+ );
167
+ return { ...normalizeConsumerState(updated), skippedEventId: smallestVisiblePending };
168
+ }
169
+
143
170
  const poisonId = await selectNextEventIdAfter(tx, before.lastProcessedEventId);
144
171
  if (poisonId === null) {
145
172
  const [unchanged] = await selectMany<ConsumerStateRow>(tx, eventConsumerStateTable, {