@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -1,6 +1,8 @@
1
1
  import { checkWriteFieldOwnership } from "../engine/field-access.js";
2
2
  import { instructionFieldNames } from "../engine/instruction-fields.js";
3
3
  import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
4
+ import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
5
+ import { findUnavailableSelectOptions, } from "../engine/screen-helpers.js";
4
6
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
5
7
  import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, UnprocessableError, writeFailure, } from "../errors/index.js";
6
8
  import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
@@ -51,12 +53,32 @@ function isFrameworkSystemUser(user) {
51
53
  function isUnchangedValue(a, b) {
52
54
  return JSON.stringify(a) === JSON.stringify(b);
53
55
  }
56
+ function selectOptionNotAvailableFailure(entries) {
57
+ const [first] = entries;
58
+ if (!first)
59
+ return undefined;
60
+ const i18nKey = "kumiko.validation.optionNotAvailable";
61
+ return writeFailure(new UnprocessableError("select_option_not_available", {
62
+ i18nKey,
63
+ details: {
64
+ field: first.field,
65
+ value: first.value,
66
+ allowed: first.allowed,
67
+ fields: entries.map((entry) => ({
68
+ path: entry.field,
69
+ code: "select_option_not_available",
70
+ i18nKey,
71
+ params: { allowed: entry.allowed },
72
+ })),
73
+ },
74
+ }));
75
+ }
54
76
  // preSave runs before ownership checks: authorization must evaluate the row
55
77
  // as it will actually be persisted, including hook-derived fields
56
78
  // (kumiko-framework#1672). A throwing hook is an app-author bug (bad
57
79
  // business rule, not a framework fault) — map it to a clean writeFailure
58
80
  // instead of letting it propagate as an internal_error 500.
59
- async function runPreSave(preSave, changes, previous, isNew, entityName, action) {
81
+ async function runPreSave(preSave, changes, previous, isNew, entityName, action, outputSchema) {
60
82
  if (!preSave)
61
83
  return { data: changes };
62
84
  try {
@@ -65,6 +87,25 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action)
65
87
  // must not leak them into the persisted row — aggregateId already comes
66
88
  // from generateId()/the loaded row, not from hook output (fw#1685).
67
89
  const { id: _hookId, version: _hookVersion, ...safe } = hookResult;
90
+ // Hook output skips the handler's input validation yet goes straight into the projection.
91
+ // Validated only, not replaced by the parse result: hooks may set fields in storage form.
92
+ const parsed = outputSchema().safeParse(safe);
93
+ if (!parsed.success) {
94
+ return {
95
+ failure: writeFailure(new UnprocessableError("presave_hook_invalid_output", {
96
+ i18nKey: "errors.presaveHookInvalidOutput",
97
+ details: {
98
+ entityName,
99
+ action,
100
+ handler: `${entityName}:${action}`,
101
+ issues: parsed.error.issues.map((issue) => ({
102
+ path: issue.path.join("."),
103
+ message: issue.message,
104
+ })),
105
+ },
106
+ })),
107
+ };
108
+ }
68
109
  return { data: safe };
69
110
  }
70
111
  catch (e) {
@@ -79,6 +120,10 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action)
79
120
  export function createWriteVerbs(ctx) {
80
121
  const { table, entity, entityName, entityCache, softDelete, streamTenantFor, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
81
122
  const entityInstructionFieldNames = instructionFieldNames(entity);
123
+ let insertSchema;
124
+ let updateSchema;
125
+ const createOutputSchema = () => (insertSchema ??= buildInsertSchema(entity));
126
+ const updateOutputSchema = () => (updateSchema ??= buildUpdateSchema(entity));
82
127
  return {
83
128
  async create(payload, user, db, options) {
84
129
  if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
@@ -94,7 +139,7 @@ export function createWriteVerbs(ctx) {
94
139
  const explicitId = typeof payload["id"] === "string" ? payload["id"] : undefined; // @cast-boundary engine-payload
95
140
  const aggregateId = explicitId ?? generateId();
96
141
  const { id: _id, ...payloadWithoutId } = payload;
97
- const preSaveResult = await runPreSave(options?.preSave, applyDefaults(payloadWithoutId), {}, true, entityName, "create");
142
+ const preSaveResult = await runPreSave(options?.preSave, applyDefaults(payloadWithoutId), {}, true, entityName, "create", createOutputSchema);
98
143
  if ("failure" in preSaveResult)
99
144
  return preSaveResult.failure;
100
145
  const data = preSaveResult.data;
@@ -132,6 +177,9 @@ export function createWriteVerbs(ctx) {
132
177
  },
133
178
  }));
134
179
  }
180
+ const unavailableOnCreate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, data));
181
+ if (unavailableOnCreate)
182
+ return unavailableOnCreate;
135
183
  // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
136
184
  // dieselbe Pipeline. Caller schickt combined API-Form, Framework
137
185
  // speichert flat DB-Form. Siehe db/compound-types.ts.
@@ -261,7 +309,7 @@ export function createWriteVerbs(ctx) {
261
309
  const previous = await loadById(payload.id, db);
262
310
  if (!previous)
263
311
  return writeFailure(new NotFoundError(entityName, payload.id));
264
- const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update");
312
+ const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update", updateOutputSchema);
265
313
  if ("failure" in preSaveResult)
266
314
  return preSaveResult.failure;
267
315
  const changes = preSaveResult.data;
@@ -316,6 +364,9 @@ export function createWriteVerbs(ctx) {
316
364
  },
317
365
  }));
318
366
  }
367
+ const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
368
+ if (unavailableOnUpdate)
369
+ return unavailableOnUpdate;
319
370
  await assertStreamWritable(db, payload.id, streamTenantFor(user));
320
371
  // Stream-version is authoritative, not row.version. `ctx.appendEvent`
321
372
  // can bump the stream between CRUD writes (domain event on the same
@@ -9,7 +9,7 @@ export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cu
9
9
  export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
10
10
  export type { SchemaTable, SelectQuery, TableColumns } from "./dialect.js";
11
11
  export { bigint, bigserial, boolean, extractTableName, index, instant, instantToDriver, integer, jsonb, moneyAmount, numeric, primaryKey, serial, sql, table, text, timestamp, uniqueIndex, uuid, } from "./dialect.js";
12
- export type { EagerLoadEntityResolver, EagerloadedRow } from "./eagerload.js";
12
+ export type { EagerLoadEntityResolver, EagerLoadViewer, EagerloadedRow } from "./eagerload.js";
13
13
  export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "./eagerload.js";
14
14
  export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.js";
15
15
  export { entityTableFromRegistry } from "./entity-table-from-registry.js";
@@ -122,7 +122,10 @@ function diffOneTable(prev, next) {
122
122
  const uniqueChanged = (prevI.unique ?? false) === (idx.unique ?? false)
123
123
  ? undefined
124
124
  : { from: prevI.unique ?? false, to: idx.unique ?? false };
125
- const whereSqlChanged = prevI.whereSql === idx.whereSql ? undefined : { from: prevI.whereSql, to: idx.whereSql };
125
+ // Whitespace-only re-rendering of the predicate must not drop and rebuild a large index.
126
+ const whereSqlChanged = whereSqlEqual(prevI.whereSql, idx.whereSql)
127
+ ? undefined
128
+ : { from: prevI.whereSql, to: idx.whereSql };
126
129
  const needsManualWhereChanged = (prevI.needsManualWhere ?? false) === (idx.needsManualWhere ?? false)
127
130
  ? undefined
128
131
  : { from: prevI.needsManualWhere ?? false, to: idx.needsManualWhere ?? false };
@@ -297,6 +300,11 @@ function renderColumnChange(tableName, change) {
297
300
  function toSingleLine(text) {
298
301
  return text.replace(/\s+/g, " ").trim();
299
302
  }
303
+ function whereSqlEqual(a, b) {
304
+ if (a === undefined || b === undefined)
305
+ return a === b;
306
+ return toSingleLine(a) === toSingleLine(b);
307
+ }
300
308
  function describeIndexChange(change) {
301
309
  const parts = [];
302
310
  if (change.columnsChanged) {
@@ -328,7 +336,7 @@ export function renderMigrationSql(diff, options) {
328
336
  lines.push(`-- Migration ${seq}_${options.name}`);
329
337
  lines.push(`-- Generated: ${new Date().toISOString()}`);
330
338
  lines.push("-- ");
331
- lines.push("-- This file is generated by `kumiko migrate generate`. You may");
339
+ lines.push("-- This file is generated by `kumiko-schema generate`. You may");
332
340
  lines.push("-- hand-edit it before committing — add partial-indexes, BRIN-");
333
341
  lines.push("-- variants, performance-tuning. After commit + apply, NEVER edit");
334
342
  lines.push("-- this file again (production-DBs already ran it). Write a new");
@@ -16,6 +16,7 @@ export type PiiBackfillResult = {
16
16
  };
17
17
  export type PiiBackfillScanCache = {
18
18
  forgottenAggregates?: ReadonlySet<string>;
19
+ destroyedTenants?: ReadonlySet<string>;
19
20
  };
20
21
  export type PiiBackfillBatchResult = PiiBackfillResult & {
21
22
  readonly lastEventId: string | null;
@@ -32,6 +33,7 @@ export type PiiBackfillOptions = {
32
33
  };
33
34
  export type PiiBackfillBatchOptions = PiiBackfillOptions & {
34
35
  readonly afterEventId?: bigint | string;
36
+ readonly eventIds?: readonly string[];
35
37
  readonly scanCache?: PiiBackfillScanCache;
36
38
  };
37
39
  export declare function backfillEventPiiEncryption(db: DbRunner, registry: Registry, options?: PiiBackfillOptions): Promise<PiiBackfillResult>;
@@ -137,11 +137,16 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
137
137
  });
138
138
  if (aggregateTypes.length === 0 && catalogTypes.length === 0)
139
139
  return emptyBatch();
140
- const rows = (await raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
141
- FROM "kumiko_events"
142
- WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
143
- ORDER BY "id" ASC
144
- LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize]));
140
+ const rows = (await (options.eventIds
141
+ ? raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
142
+ FROM "kumiko_events"
143
+ WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" = ANY($3::bigint[])
144
+ ORDER BY "id" ASC`, [aggregateTypes, catalogTypes, [...options.eventIds]])
145
+ : raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
146
+ FROM "kumiko_events"
147
+ WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
148
+ ORDER BY "id" ASC
149
+ LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize])));
145
150
  const last = rows[rows.length - 1];
146
151
  if (last === undefined)
147
152
  return emptyBatch();
@@ -192,7 +197,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
192
197
  return {
193
198
  ...result,
194
199
  lastEventId: String(last.id),
195
- scannedAll: rows.length < batchSize,
200
+ scannedAll: options.eventIds !== undefined || rows.length < batchSize,
196
201
  firstFailedEventId,
197
202
  touchedAggregateTypes: [...touchedAggregateTypes],
198
203
  touchedEventTypes: [...touchedEventTypes],
@@ -380,8 +385,21 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
380
385
  const forgottenAggregates = await loadForgottenAggregates();
381
386
  if (forgottenAggregates.has(aggregateId))
382
387
  return true;
388
+ if (subject.kind === "record" && subject.entity === "tenant") {
389
+ return (await loadDestroyedTenants()).has(subject.id);
390
+ }
383
391
  return subject.kind === "user" && forgottenAggregates.has(subject.userId);
384
392
  }
393
+ // A tenant destroyed before its name was record-owned never had a record key
394
+ // to erase, and destroy tombstones the row instead of emitting *.forgotten —
395
+ // read_tenants.destroyed_at is the only durable marker.
396
+ async function loadDestroyedTenants() {
397
+ if (scanCache.destroyedTenants)
398
+ return scanCache.destroyedTenants;
399
+ const rows = (await raw.unsafe(`SELECT id::text AS id FROM read_tenants WHERE destroyed_at IS NOT NULL`));
400
+ scanCache.destroyedTenants = new Set(rows.map((r) => r.id));
401
+ return scanCache.destroyedTenants;
402
+ }
385
403
  }
386
404
  // Stage 1 (payload) then stage 2 (projection row, when supplied) — stage 3
387
405
  // (erase) is the caller's call, made from the "unresolved" outcome.
@@ -9,14 +9,13 @@ export declare function selectEventIdHorizonWithMissingRanges(db: AnyDb): Promis
9
9
  readonly gaps: PendingGapEntry[];
10
10
  }>;
11
11
  export declare function insertConsumerIfAbsent(db: AnyDb, name: string, instanceId: string, startFrom?: "beginning" | "now"): Promise<void>;
12
- export declare function deleteOrphanedPerInstanceConsumerRows(db: AnyDb, consumerNames: readonly string[], sharedInstanceSentinel: string): Promise<void>;
13
12
  export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus: typeof ConsumerStatuses.dead): Promise<ReadonlyArray<{
14
13
  readonly name: string;
15
14
  readonly instanceId: string;
16
15
  }>>;
17
16
  export declare function selectConsumerForUpdateSkipLocked(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
18
17
  export declare function markConsumerProcessing(db: AnyDb, name: string, instanceId: string): Promise<void>;
19
- export declare function recordConsumerPassFailure(db: AnyDb, name: string, instanceId: string, errorMessage: string): Promise<void>;
18
+ export declare function recordConsumerPassFailure(db: AnyDb, name: string, instanceId: string, errorMessage: string, cursorBeforePass?: bigint): Promise<void>;
20
19
  export type ConsumerDeliveryOutcome = {
21
20
  readonly cursor: bigint;
22
21
  readonly attempts: number;
@@ -28,7 +27,7 @@ export type ConsumerDeliveryOutcome = {
28
27
  };
29
28
  export declare function updateConsumerDeliveryOutcome(db: AnyDb, name: string, instanceId: string, outcome: ConsumerDeliveryOutcome): Promise<void>;
30
29
  export declare function updateConsumerStatusReturning(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<Record<string, unknown> | undefined>;
31
- export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, gapToAdd?: PendingGapEntry | null): Promise<Record<string, unknown> | undefined>;
30
+ export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
32
31
  export declare function resetConsumerForMspRebuild(db: AnyDb, name: string, instanceId: string): Promise<void>;
33
32
  export declare function selectConsumerForUpdate(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
34
33
  export declare function updateConsumerRebuildCursor(db: AnyDb, name: string, instanceId: string, lastProcessedEventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<void>;
@@ -23,28 +23,46 @@ export async function lockEventConsumersShareMode(db) {
23
23
  await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
24
24
  }
25
25
  // The MAX(id) horizon plus every currently-open id gap below it, read from a
26
- // single snapshot (READ COMMITTED gives each statement its own). A gap can
27
- // only later fill in if some transaction holding a lower id was still
28
- // in-flight in that same snapshot — pg_snapshot_xip is empty otherwise, so
29
- // the gap CTEs short-circuit and the window scan over kumiko_events never
30
- // runs. Used both to seed startFrom "now" (#3231) and to seed an MSP
31
- // rebuild's handoff to the live dispatcher (msp-rebuild.ts).
26
+ // single statement snapshot. Used both to seed startFrom "now" (#3231) and to
27
+ // seed an MSP rebuild's handoff to the live dispatcher (msp-rebuild.ts).
28
+ //
29
+ // Scan lower bound: the consumer with the highest cursor already tracks every
30
+ // open gap at or below that cursor in its own pending_gaps, so those are taken
31
+ // over verbatim (keeping their xmax) and only ids above the cursor are scanned,
32
+ // with the cursor itself as virtual predecessor. Without any consumer the
33
+ // cursor is 0 and the scan covers the whole table. No bound derived from xmin or
34
+ // id margins: ids and xids interleave, so such a bound cannot be proven safe.
35
+ // Above the cursor a gap can only fill in if some transaction holding a lower id
36
+ // is still in flight, which pg_snapshot_xip being empty rules out, so the window
37
+ // scan is skipped then.
32
38
  export async function selectEventIdHorizonWithMissingRanges(db) {
33
- const rows = (await asRawClient(db).unsafe(`WITH b AS (
34
- SELECT COALESCE(MAX("id"), 0) AS horizon, MIN("id") AS min_id FROM "kumiko_events"
39
+ const rows = (await asRawClient(db).unsafe(`WITH a AS (
40
+ SELECT "last_processed_event_id" AS cursor, "pending_gaps" AS gaps
41
+ FROM "kumiko_event_consumers"
42
+ ORDER BY "last_processed_event_id" DESC
43
+ LIMIT 1
35
44
  ),
45
+ c AS (SELECT COALESCE((SELECT cursor FROM a), 0) AS v),
46
+ b AS (SELECT COALESCE(MAX("id"), 0) AS horizon FROM "kumiko_events"),
36
47
  r AS (
37
- SELECT 1::bigint AS f, b.min_id - 1 AS t FROM b
38
- WHERE b.min_id > 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
48
+ SELECT (g ->> 'from')::bigint AS f, (g ->> 'to')::bigint AS t, g ->> 'xmax' AS x
49
+ FROM a,
50
+ jsonb_array_elements(CASE WHEN jsonb_typeof(a.gaps) = 'array' THEN a.gaps ELSE '[]'::jsonb END) AS g
51
+ UNION ALL
52
+ SELECT c.v + 1, m.min_id - 1, pg_snapshot_xmax(pg_current_snapshot())::text
53
+ FROM c,
54
+ (SELECT MIN(e."id") AS min_id FROM "kumiko_events" e WHERE e."id" > (SELECT v FROM c)) m
55
+ WHERE m.min_id > c.v + 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
39
56
  UNION ALL
40
- SELECT w."id" + 1, w.next_id - 1
41
- FROM (SELECT "id", lead("id") OVER (ORDER BY "id") AS next_id FROM "kumiko_events") w
57
+ SELECT w."id" + 1, w.next_id - 1, pg_snapshot_xmax(pg_current_snapshot())::text
58
+ FROM (SELECT e."id", lead(e."id") OVER (ORDER BY e."id") AS next_id
59
+ FROM "kumiko_events" e WHERE e."id" > (SELECT v FROM c)) w
42
60
  WHERE w.next_id > w."id" + 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
43
61
  )
44
62
  SELECT b.horizon::text AS horizon,
45
- pg_snapshot_xmax(pg_current_snapshot())::text AS xmax,
46
63
  r.f::text AS gap_from,
47
- r.t::text AS gap_to
64
+ r.t::text AS gap_to,
65
+ r.x AS gap_xmax
48
66
  FROM b LEFT JOIN r ON true
49
67
  ORDER BY r.f`));
50
68
  const first = rows[0];
@@ -52,9 +70,9 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
52
70
  throw new Error("selectEventIdHorizonWithMissingRanges: no row returned");
53
71
  const gaps = [];
54
72
  for (const row of rows) {
55
- if (row.gap_from === null || row.gap_to === null)
73
+ if (row.gap_from === null || row.gap_to === null || row.gap_xmax === null)
56
74
  continue;
57
- gaps.push({ from: row.gap_from, to: row.gap_to, xmax: row.xmax });
75
+ gaps.push({ from: row.gap_from, to: row.gap_to, xmax: row.gap_xmax });
58
76
  }
59
77
  return { horizon: BigInt(first.horizon), gaps };
60
78
  }
@@ -85,26 +103,6 @@ export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "
85
103
  ON CONFLICT ("name", "instance_id") DO NOTHING`, [name, instanceId]);
86
104
  }
87
105
  }
88
- // fw#2625: a consumer that moved from delivery: "per-instance" to "shared"
89
- // leaves its old per-instance rows behind — no dispatcher will ever advance
90
- // them again, and pruneEvents stays pinned to their stale cursor forever.
91
- // Scoped by name + "not the shared sentinel" so a still-per-instance
92
- // consumer's live rows (or an already-migrated __shared__ row) are never
93
- // touched.
94
- export async function deleteOrphanedPerInstanceConsumerRows(db, consumerNames,
95
- // Passed in rather than imported from event-consumer-state.ts (which
96
- // defines SHARED_INSTANCE_SENTINEL): that module already imports from
97
- // this one for the DB queries it needs, so importing back would be a
98
- // require cycle. The one caller lives in that same file and has the
99
- // constant in scope.
100
- sharedInstanceSentinel) {
101
- // skip: nothing to delete — an empty ANY($1) would still hit the table,
102
- // pointlessly, on every boot for an app with no migrated consumers yet.
103
- if (consumerNames.length === 0)
104
- return;
105
- await asRawClient(db).unsafe(`DELETE FROM "kumiko_event_consumers"
106
- WHERE "name" = ANY($1) AND "instance_id" != $2`, [consumerNames, sharedInstanceSentinel]);
107
- }
108
106
  // Lock-free pre-check for doPass: which (name, instance_id) pairs are
109
107
  // provably idle — no locking, no xid, no WAL. A pair only qualifies when its
110
108
  // row exists, its status isn't dead (dead must still go through
@@ -115,7 +113,7 @@ sharedInstanceSentinel) {
115
113
  // `deadStatus` is passed in rather than imported as a value from
116
114
  // event-consumer-state.ts (which defines ConsumerStatuses) — that module
117
115
  // already imports from this one, so a value import back would be a require
118
- // cycle (same pattern as deleteOrphanedPerInstanceConsumerRows above). The
116
+ // cycle. The
119
117
  // type-only import keeps the parameter pinned to the "dead" literal.
120
118
  export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
121
119
  const rows = (await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
@@ -148,11 +146,24 @@ export async function markConsumerProcessing(db, name, instanceId) {
148
146
  // caller's console.error — status and the cursor are left as-is too, since
149
147
  // we don't know at this point whether the consumer should be "dead" (that's
150
148
  // the deliverEvents/maxAttempts contract, which never ran this pass).
151
- export async function recordConsumerPassFailure(db, name, instanceId, errorMessage) {
149
+ //
150
+ // Another replica sharing this (name, instance_id) may commit a successful
151
+ // pass between the rollback and this write. `cursorBeforePass` pins the
152
+ // write to the state the failed pass started from, so a stale error can't
153
+ // overwrite that success; a row another pass currently holds is skipped
154
+ // instead of blocking this catch path on its lock. Undefined when the pass
155
+ // failed before it read the cursor.
156
+ export async function recordConsumerPassFailure(db, name, instanceId, errorMessage, cursorBeforePass) {
152
157
  await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
153
158
  "last_error" = $1,
154
159
  "updated_at" = now()
155
- WHERE "name" = $2 AND "instance_id" = $3`, [errorMessage, name, instanceId]);
160
+ WHERE "name" = $2 AND "instance_id" = $3
161
+ AND ($4::bigint IS NULL OR "last_processed_event_id" = $4::bigint)
162
+ AND ("name", "instance_id") IN (
163
+ SELECT "name", "instance_id" FROM "kumiko_event_consumers"
164
+ WHERE "name" = $2 AND "instance_id" = $3
165
+ FOR UPDATE SKIP LOCKED
166
+ )`, [errorMessage, name, instanceId, cursorBeforePass ?? null]);
156
167
  }
157
168
  export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcome) {
158
169
  // Advancing the cursor proves this pass wasn't poison — reset the re-arm
@@ -189,8 +200,8 @@ export async function updateConsumerStatusReturning(db, name, instanceId, status
189
200
  return rows[0];
190
201
  }
191
202
  export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId,
192
- // Ids skipped over between the old cursor and eventId that were not visible yet.
193
- gapToAdd = null) {
203
+ // Full list, including ids skipped over between the old cursor and eventId that were not visible yet.
204
+ pendingGaps) {
194
205
  const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
195
206
  "last_processed_event_id" = $1,
196
207
  "status" = 'idle',
@@ -199,10 +210,10 @@ gapToAdd = null) {
199
210
  "rearm_count" = 0,
200
211
  "last_failed_event_id" = NULL,
201
212
  -- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
202
- "pending_gaps" = CASE WHEN $4::text IS NULL THEN "pending_gaps" ELSE "pending_gaps" || $4::text::jsonb END,
213
+ "pending_gaps" = $4::text::jsonb,
203
214
  "updated_at" = now()
204
215
  WHERE "name" = $2 AND "instance_id" = $3
205
- RETURNING *`, [eventId, name, instanceId, gapToAdd === null ? null : JSON.stringify([gapToAdd])]));
216
+ RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)]));
206
217
  return rows[0];
207
218
  }
208
219
  export async function resetConsumerForMspRebuild(db, name, instanceId) {
@@ -1,8 +1,12 @@
1
+ import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
1
2
  import type { AnyDb } from "../query.js";
2
3
  export declare function claimXactId(db: AnyDb): Promise<void>;
4
+ export declare function runAtomicEventWrite<T>(db: DbRunner, write: (runner: DbRunner) => Promise<T>): Promise<T>;
3
5
  /** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
4
6
  export declare function notifyPgChannel(db: AnyDb, channel: string): Promise<void>;
7
+ export declare const MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME = "events_membership_payload_tenant_idx";
5
8
  export declare function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void>;
9
+ export declare function ensureMembershipPayloadTenantIndex(db: AnyDb): Promise<void>;
6
10
  export type SubsequentEventInsertParams = {
7
11
  readonly aggregateId: string;
8
12
  readonly aggregateType: string;
@@ -1,5 +1,5 @@
1
1
  import { constraintOf, isLockNotAvailable, isTableAlreadyExists, isUniqueViolation, } from "../pg-error.js";
2
- import { asRawClient, unsafeReadRetrying } from "../query.js";
2
+ import { asRawClient, runInNewTransaction, unsafeReadRetrying } from "../query.js";
3
3
  // Gap-finality (event-dispatcher pending_gaps) needs every holder of an
4
4
  // event id to already have a *real* xact id by the time it inserts — Postgres
5
5
  // assigns those lazily on first write, so a bare INSERT alone doesn't
@@ -8,10 +8,25 @@ import { asRawClient, unsafeReadRetrying } from "../query.js";
8
8
  export async function claimXactId(db) {
9
9
  await asRawClient(db).unsafe(`SELECT pg_current_xact_id()`);
10
10
  }
11
+ // Outside a transaction every statement commits on its own, so claim, insert and
12
+ // notify would be three commits and the claimed xact id would be released before
13
+ // the insert's. A pool runner (has begin()) therefore gets one transaction around
14
+ // the whole write; inside a caller's tx or savepoint the caller's tx already is
15
+ // that unit. Bun.SQL's TransactionSQL extends SQL and still exposes begin(), so
16
+ // only a runner without savepoint() counts as a pool.
17
+ export async function runAtomicEventWrite(db, write) {
18
+ const raw = asRawClient(db);
19
+ const isPool = typeof raw.begin === "function" && typeof raw.savepoint !== "function";
20
+ if (!isPool)
21
+ return write(db);
22
+ return runInNewTransaction(db, (tx) => write(tx));
23
+ }
11
24
  /** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
12
25
  export async function notifyPgChannel(db, channel) {
13
26
  await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
14
27
  }
28
+ const IDEMPOTENCY_INDEX_NAME = "events_idempotency_uq";
29
+ export const MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME = "events_membership_payload_tenant_idx";
15
30
  // Tenant-scoped partial unique index over metadata.idempotencyKey.
16
31
  // Expression index straight on the jsonb column — no dedicated key column,
17
32
  // so it needs no INSERT-path change and covers admin-api's raw appends too
@@ -30,9 +45,9 @@ export async function notifyPgChannel(db, channel) {
30
45
  // undefined = index doesn't exist at all (nothing to drop, CREATE below
31
46
  // handles it); false = exists but INVALID (crashed mid-build, needs DROP +
32
47
  // rebuild); true = exists and valid.
33
- async function indexValidity(client) {
48
+ async function indexValidity(client, indexName = IDEMPOTENCY_INDEX_NAME) {
34
49
  const rows = await client.unsafe(`SELECT i.indisvalid FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid ` +
35
- `WHERE c.relname = 'events_idempotency_uq' AND i.indrelid = '"kumiko_events"'::regclass`);
50
+ `WHERE c.relname = $1 AND i.indrelid = '"kumiko_events"'::regclass`, [indexName]);
36
51
  return rows[0]?.indisvalid;
37
52
  }
38
53
  export async function ensureIdempotencyKeyIndex(db) {
@@ -72,6 +87,28 @@ export async function ensureIdempotencyKeyIndex(db) {
72
87
  throw duplicateIdempotencyKeyErrorOr(e);
73
88
  }
74
89
  }
90
+ // forget-cleanup reads the membership history by payload->>'tenantId' (not the
91
+ // tenant_id column — cross-tenant SystemAdmin adds land under the actor's
92
+ // tenant), which no other index covers: without this it scans every event.
93
+ // Same CONCURRENTLY + INVALID-rebuild handling as the idempotency index.
94
+ export async function ensureMembershipPayloadTenantIndex(db) {
95
+ const client = asRawClient(db);
96
+ try {
97
+ if ((await indexValidity(client, MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME)) === false) {
98
+ await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS "${MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME}"`);
99
+ }
100
+ await client.unsafe(`CREATE INDEX CONCURRENTLY IF NOT EXISTS "${MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME}" ON "kumiko_events" ` +
101
+ `((("payload"->>'tenantId'))) WHERE "aggregate_type" = 'tenant-membership'`);
102
+ }
103
+ catch (e) {
104
+ if (isBenignConcurrentIndexBuildRace(e)) {
105
+ // skip: sibling pod already built a valid index — this race loser is done.
106
+ if ((await indexValidity(client, MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME)) === true)
107
+ return;
108
+ }
109
+ throw e;
110
+ }
111
+ }
75
112
  // Two pods booting concurrently against the same DB (rolling deploy): both
76
113
  // see the index missing/invalid and both start a DROP/CREATE CONCURRENTLY
77
114
  // build. The loser typically does NOT get the plain duplicate-relation
@@ -2,6 +2,13 @@ import type { AnyDb } from "../query.js";
2
2
  export type PiiBackfillStateRow = {
3
3
  readonly cursor: string;
4
4
  readonly completed: boolean;
5
+ readonly failedEventIds: readonly string[];
5
6
  };
6
7
  export declare function readPiiBackfillState(db: AnyDb, fingerprint: string): Promise<PiiBackfillStateRow | null>;
7
- export declare function persistPiiBackfillState(db: AnyDb, fingerprint: string, cursor: bigint, markCompleted: boolean): Promise<void>;
8
+ export type PiiBackfillStateUpdate = {
9
+ readonly cursor: bigint;
10
+ readonly markCompleted: boolean;
11
+ readonly addFailedEventIds: readonly string[];
12
+ readonly removeFailedEventIds: readonly string[];
13
+ };
14
+ export declare function persistPiiBackfillState(db: AnyDb, fingerprint: string, update: PiiBackfillStateUpdate): Promise<void>;
@@ -1,14 +1,25 @@
1
1
  import { asRawClient } from "../query.js";
2
2
  export async function readPiiBackfillState(db, fingerprint) {
3
- const rows = (await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed"
3
+ const rows = (await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
4
+ "failed_event_ids"::text[] AS "failedEventIds"
4
5
  FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint]));
5
6
  return rows[0] ?? null;
6
7
  }
7
- export async function persistPiiBackfillState(db, fingerprint, cursor, markCompleted) {
8
- await asRawClient(db).unsafe(`INSERT INTO "kumiko_pii_backfill_state" ("fingerprint", "cursor_event_id", "completed_at", "updated_at")
9
- VALUES ($1, $2::bigint, CASE WHEN $3::boolean THEN now() ELSE NULL END, now())
8
+ export async function persistPiiBackfillState(db, fingerprint, update) {
9
+ await asRawClient(db).unsafe(`INSERT INTO "kumiko_pii_backfill_state" ("fingerprint", "cursor_event_id", "completed_at", "failed_event_ids", "updated_at")
10
+ VALUES ($1, $2::bigint, CASE WHEN $3::boolean THEN now() ELSE NULL END, $4::bigint[], now())
10
11
  ON CONFLICT ("fingerprint") DO UPDATE SET
11
12
  "cursor_event_id" = GREATEST("kumiko_pii_backfill_state"."cursor_event_id", EXCLUDED."cursor_event_id"),
12
13
  "completed_at" = COALESCE("kumiko_pii_backfill_state"."completed_at", EXCLUDED."completed_at"),
13
- "updated_at" = now()`, [fingerprint, cursor.toString(), markCompleted]);
14
+ "failed_event_ids" = ARRAY(
15
+ SELECT DISTINCT failed FROM unnest("kumiko_pii_backfill_state"."failed_event_ids" || EXCLUDED."failed_event_ids") AS failed
16
+ WHERE failed <> ALL($5::bigint[])
17
+ ORDER BY failed),
18
+ "updated_at" = now()`, [
19
+ fingerprint,
20
+ update.cursor.toString(),
21
+ update.markCompleted,
22
+ [...update.addFailedEventIds],
23
+ [...update.removeFailedEventIds],
24
+ ]);
14
25
  }
@@ -266,21 +266,50 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
266
266
  `derived from the source event declare \`rowIdOf\` on r.extendEntityProjection. See ` +
267
267
  `docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`);
268
268
  }
269
+ // A key is configured, but not necessarily the one the live bidx values were
270
+ // computed with (typo'd secret, key rotated in only one of migrate-db / app).
271
+ // The replay would then rewrite every bidx column with the wrong key and the
272
+ // swap would succeed without any error. A NULL on either side is skipped: GDPR
273
+ // shredding legitimately nulls a bidx, and a plaintext-era row has none yet.
274
+ async function assertBlindIndexKeyMatchesLive(raw, tableName, quotedTable, bidxCols, projectionName) {
275
+ const differs = bidxCols
276
+ .map((c) => {
277
+ const col = quoteTableIdent(c.name);
278
+ return `(l.${col} IS NOT NULL AND s.${col} IS NOT NULL AND l.${col} IS DISTINCT FROM s.${col})`;
279
+ })
280
+ .join(" OR ");
281
+ const rows = await raw.unsafe(`SELECT count(*)::text AS total
282
+ FROM public.${quotedTable} l
283
+ JOIN ${SCHEMA_IDENT}.${quotedTable} s ON s."id" = l."id"
284
+ WHERE ${differs}`);
285
+ const count = Number(rows[0]?.total ?? "0");
286
+ // skip: every comparable bidx value matches the replay — same key as the live data
287
+ if (count === 0)
288
+ return;
289
+ throw new Error(`projection-rebuild "${projectionName}": "${tableName}" has ${count} row(s) whose blind-index ` +
290
+ `value differs from the replayed one. The KUMIKO_BLIND_INDEX_KEY in this process is not the ` +
291
+ `key those values were computed with, so the swap would break equality lookups on that ` +
292
+ `field (login, password reset) without any error. Use the same KUMIKO_BLIND_INDEX_KEY as ` +
293
+ `the running app in the process running this apply/rebuild. Rebuild aborted; live table ` +
294
+ `untouched.`);
295
+ }
269
296
  // The bidx column is schema-driven, not key-driven: it exists NULL in a
270
297
  // plaintext install and in the fw#1610 case (KMS configured, no index key),
271
298
  // both of which are correct as-is. Only a POPULATED column with no key
272
299
  // configured in THIS process means the rebuild is about to overwrite proof
273
300
  // of a real index with NULL — that's the one provable data-loss case.
274
301
  export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName) {
275
- // skip: a key is configured — the replay recomputes every bidx column with it
276
- if (configuredBlindIndexKey() !== undefined)
277
- return;
278
302
  const bidxCols = meta.columns.filter((c) => c.name.endsWith("_bidx"));
279
303
  // skip: no blind-index column on this table — nothing the rebuild could lose
280
304
  if (bidxCols.length === 0)
281
305
  return;
282
306
  const t = quoteTableIdent(tableName);
283
307
  const raw = asRawClient(tx);
308
+ if (configuredBlindIndexKey() !== undefined) {
309
+ await assertBlindIndexKeyMatchesLive(raw, tableName, t, bidxCols, projectionName);
310
+ // skip: a configured key is verified against live rows above, the null-count check below is for keyless boots
311
+ return;
312
+ }
284
313
  const where = bidxCols.map((c) => `${quoteTableIdent(c.name)} IS NOT NULL`).join(" OR ");
285
314
  const rows = await raw.unsafe(`SELECT count(*)::text AS total FROM public.${t} WHERE ${where}`);
286
315
  const count = Number(rows[0]?.total ?? "0");
@@ -362,10 +391,13 @@ export async function countColumnDrift(tx, tableName, meta) {
362
391
  // object depends on the live table the swap fails loud and the whole rebuild
363
392
  // rolls back, leaving the old table untouched.
364
393
  export async function swapShadowIntoLive(tx, tableName) {
365
- // Re-checked right before DROP: RLS could have been enabled after the early check.
366
- await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
367
394
  const raw = asRawClient(tx);
368
395
  const ident = quoteTableIdent(tableName);
396
+ // Held before the re-check so a concurrent ENABLE ROW LEVEL SECURITY either commits first
397
+ // (and is seen) or waits behind the DROP. Without it the catalog read takes no lock, and
398
+ // the unfenced paths (MSP rebuild, projection with no subscribed events) would drop RLS.
399
+ await raw.unsafe(`LOCK TABLE public.${ident} IN ACCESS EXCLUSIVE MODE`);
400
+ await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
369
401
  await raw.unsafe(`DROP TABLE public.${ident}`);
370
402
  await raw.unsafe(`ALTER TABLE ${SCHEMA_IDENT}.${ident} SET SCHEMA public`);
371
403
  }