@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1

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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. 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";
@@ -336,7 +336,7 @@ export function renderMigrationSql(diff, options) {
336
336
  lines.push(`-- Migration ${seq}_${options.name}`);
337
337
  lines.push(`-- Generated: ${new Date().toISOString()}`);
338
338
  lines.push("-- ");
339
- 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");
340
340
  lines.push("-- hand-edit it before committing — add partial-indexes, BRIN-");
341
341
  lines.push("-- variants, performance-tuning. After commit + apply, NEVER edit");
342
342
  lines.push("-- this file again (production-DBs already ran it). Write a new");
@@ -33,6 +33,7 @@ export type PiiBackfillOptions = {
33
33
  };
34
34
  export type PiiBackfillBatchOptions = PiiBackfillOptions & {
35
35
  readonly afterEventId?: bigint | string;
36
+ readonly eventIds?: readonly string[];
36
37
  readonly scanCache?: PiiBackfillScanCache;
37
38
  };
38
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],
@@ -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;
@@ -23,6 +27,7 @@ export type SubsequentEventInsertRow = {
23
27
  /** INSERT … SELECT … WHERE EXISTS predecessor — stays raw (typed builder can't express this). */
24
28
  export declare function insertSubsequentEventRow(db: AnyDb, params: SubsequentEventInsertParams): Promise<SubsequentEventInsertRow | undefined>;
25
29
  export declare function selectStreamMaxVersion(db: AnyDb, aggregateId: string, tenantId: string): Promise<number>;
30
+ export declare function selectStreamMaxVersions(db: AnyDb, aggregateIds: readonly string[], tenantId: string): Promise<Map<string, number>>;
26
31
  /** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
27
32
  export declare function selectAggregateMaxVersion(db: AnyDb, aggregateId: string): Promise<number>;
28
33
  export declare function selectEventsHighWaterMark(db: AnyDb): Promise<bigint>;
@@ -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
@@ -134,6 +171,17 @@ export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
134
171
  const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "tenant_id" = $2`, [aggregateId, tenantId]);
135
172
  return rows[0]?.v ?? 0;
136
173
  }
174
+ export async function selectStreamMaxVersions(db, aggregateIds, tenantId) {
175
+ const versions = new Map();
176
+ if (aggregateIds.length === 0)
177
+ return versions;
178
+ const rows = await unsafeReadRetrying(db, `SELECT "aggregate_id" AS id, MAX("version") AS v FROM "kumiko_events" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2::uuid[]) GROUP BY "aggregate_id"`, [tenantId, [...aggregateIds]]);
179
+ for (const row of rows) {
180
+ if (row.v !== null)
181
+ versions.set(row.id, row.v);
182
+ }
183
+ return versions;
184
+ }
137
185
  /** MAX(version) for one aggregate stream — no tenant filter (seed idempotency). */
138
186
  export async function selectAggregateMaxVersion(db, aggregateId) {
139
187
  const rows = await unsafeReadRetrying(db, `SELECT MAX("version") AS v FROM "kumiko_events" WHERE "aggregate_id" = $1`, [aggregateId]);
@@ -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");
@@ -1,11 +1,11 @@
1
1
  // Pure renderer: EntityTableMeta → SQL DDL statements.
2
- // Wird vom Migrate-Generator (Phase 2 — CLI-Tool `kumiko migrate generate`)
3
- // genutzt um initial-SQL-Files zu schreiben. Output ist Start-Form für
4
- // User-Review — App-Author darf das SQL danach hand-editieren (extra-Index,
5
- // partial-Index, BRIN, custom-clauses) bevor committed wird.
2
+ // The migrate generator (`kumiko-schema generate`) uses it to write the
3
+ // initial SQL files. The output is a starting point for review: the app
4
+ // author may hand-edit the SQL (extra index, partial index, BRIN, custom
5
+ // clauses) before committing it.
6
6
  //
7
- // NO-MAGIC-ON-DATA: dieser Renderer wird NIE zur App-Runtime aufgerufen.
8
- // Nur Build-Step. Runner liest checked-in SQL, nicht Renderer-Output.
7
+ // NO-MAGIC-ON-DATA: this renderer never runs at app runtime, only as a build
8
+ // step. The runner reads the checked-in SQL, not renderer output.
9
9
  import { pgTypeToSqlType } from "./dialect.js";
10
10
  function quoteIdent(name) {
11
11
  return `"${name.replace(/"/g, '""')}"`;