@cosmicdrift/kumiko-framework 0.334.0 → 0.335.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 (157) hide show
  1. package/dist/api/auth-routes.js +19 -4
  2. package/dist/api/client-ip.js +3 -6
  3. package/dist/api/extra-route.js +3 -2
  4. package/dist/api/pii-leak-guard.js +7 -4
  5. package/dist/api/redis-sse-broker.d.ts +2 -0
  6. package/dist/api/redis-sse-broker.js +6 -7
  7. package/dist/api/routes.js +16 -17
  8. package/dist/api/server.js +14 -7
  9. package/dist/api/sse-broker.d.ts +3 -0
  10. package/dist/api/sse-broker.js +14 -0
  11. package/dist/api/sse-route.d.ts +1 -1
  12. package/dist/api/sse-route.js +39 -6
  13. package/dist/bun-db/connection.js +0 -2
  14. package/dist/bun-db/index.d.ts +1 -1
  15. package/dist/bun-db/index.js +1 -1
  16. package/dist/bun-db/query.d.ts +1 -0
  17. package/dist/bun-db/query.js +15 -4
  18. package/dist/crypto/kek-source.js +40 -36
  19. package/dist/crypto/kms-adapter.js +2 -2
  20. package/dist/db/apply-entity-event.js +8 -2
  21. package/dist/db/blind-index-cleanup.js +11 -1
  22. package/dist/db/entity-table-meta.js +5 -3
  23. package/dist/db/event-store-executor-context.js +13 -19
  24. package/dist/db/event-store-executor-read.js +39 -36
  25. package/dist/db/event-store-executor-write.js +15 -17
  26. package/dist/db/migrate-generator.js +27 -28
  27. package/dist/db/pg-error.js +9 -5
  28. package/dist/db/queries/backfill-pii.js +15 -5
  29. package/dist/db/queries/shadow-swap.js +5 -4
  30. package/dist/db/table-builder.d.ts +1 -0
  31. package/dist/db/table-builder.js +7 -15
  32. package/dist/db/tenant-db.js +18 -2
  33. package/dist/derivatives/variant-key.js +3 -1
  34. package/dist/engine/boot-validator/access-declarations.js +12 -3
  35. package/dist/engine/boot-validator/entity-handler.js +13 -1
  36. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  37. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  38. package/dist/engine/boot-validator/index.js +6 -6
  39. package/dist/engine/boot-validator/nav.d.ts +2 -1
  40. package/dist/engine/boot-validator/nav.js +10 -20
  41. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  42. package/dist/engine/boot-validator/ownership.js +2 -1
  43. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  44. package/dist/engine/boot-validator/pii-retention.js +5 -2
  45. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  46. package/dist/engine/boot-validator/query-output-columns.js +41 -5
  47. package/dist/engine/boot-validator/screens.d.ts +2 -2
  48. package/dist/engine/boot-validator/screens.js +103 -33
  49. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  50. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  51. package/dist/engine/boot-validator/zod-shape.js +17 -4
  52. package/dist/engine/build-app-schema.js +62 -19
  53. package/dist/engine/build-config-feature-schema.js +8 -1
  54. package/dist/engine/changeset-changes.js +24 -10
  55. package/dist/engine/feature-ast/extractors/events.js +11 -8
  56. package/dist/engine/feature-ast/extractors/handlers.js +38 -16
  57. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  58. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  59. package/dist/engine/feature-ast/parse.d.ts +1 -0
  60. package/dist/engine/feature-ast/parse.js +18 -3
  61. package/dist/engine/feature-ast/patch.js +80 -61
  62. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  63. package/dist/engine/feature-ast/patcher.js +2 -1
  64. package/dist/engine/feature-ast/render.js +1 -1
  65. package/dist/engine/index.d.ts +4 -3
  66. package/dist/engine/index.js +3 -2
  67. package/dist/engine/ownership.d.ts +1 -0
  68. package/dist/engine/ownership.js +15 -2
  69. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  70. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  71. package/dist/engine/pattern-library/shared-fields.js +17 -0
  72. package/dist/engine/registry-facade.js +3 -0
  73. package/dist/engine/role-assignment.d.ts +7 -1
  74. package/dist/engine/role-assignment.js +27 -7
  75. package/dist/engine/schema-builder.d.ts +1 -0
  76. package/dist/engine/schema-builder.js +9 -2
  77. package/dist/engine/screen-helpers.js +15 -4
  78. package/dist/engine/steps/_event-match.js +22 -3
  79. package/dist/engine/steps/_read-source.js +8 -0
  80. package/dist/engine/where-rule-lint.js +22 -4
  81. package/dist/env/index.js +21 -1
  82. package/dist/errors/reasons.d.ts +0 -1
  83. package/dist/errors/reasons.js +2 -5
  84. package/dist/event-store/event-store.js +6 -1
  85. package/dist/event-store/provenance-append.js +4 -1
  86. package/dist/files/file-routes.d.ts +0 -1
  87. package/dist/files/file-routes.js +22 -7
  88. package/dist/files/local-provider.js +13 -10
  89. package/dist/files/provider-resolver.js +20 -1
  90. package/dist/files/types.js +16 -1
  91. package/dist/i18n/request-locale.d.ts +3 -2
  92. package/dist/i18n/request-locale.js +16 -6
  93. package/dist/i18n/required-surface-keys.js +23 -35
  94. package/dist/i18n/settings-hub-keys.js +3 -0
  95. package/dist/jobs/index.d.ts +2 -2
  96. package/dist/jobs/index.js +1 -1
  97. package/dist/jobs/job-runner.d.ts +2 -0
  98. package/dist/jobs/job-runner.js +150 -59
  99. package/dist/migrations/index.d.ts +1 -1
  100. package/dist/migrations/index.js +1 -1
  101. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  102. package/dist/migrations/pending-rebuilds.js +23 -1
  103. package/dist/observability/escape-hatch-report.d.ts +1 -0
  104. package/dist/observability/escape-hatch-report.js +3 -0
  105. package/dist/observability/index.d.ts +1 -0
  106. package/dist/observability/index.js +1 -0
  107. package/dist/pipeline/dispatch-query.d.ts +1 -1
  108. package/dist/pipeline/dispatch-query.js +2 -2
  109. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  110. package/dist/pipeline/dispatch-shared.js +14 -9
  111. package/dist/pipeline/dispatch-stream.js +2 -5
  112. package/dist/pipeline/dispatch-write.js +22 -5
  113. package/dist/pipeline/dispatcher.js +1 -2
  114. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  115. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  116. package/dist/pipeline/event-dispatcher.js +26 -11
  117. package/dist/pipeline/index.d.ts +1 -1
  118. package/dist/pipeline/index.js +1 -1
  119. package/dist/pipeline/member-read-only-transaction.js +1 -14
  120. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  121. package/dist/pipeline/msp-rebuild.js +1 -0
  122. package/dist/pipeline/system-hooks.d.ts +1 -1
  123. package/dist/pipeline/system-hooks.js +9 -7
  124. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  125. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  126. package/dist/redis/pubsub-signal.d.ts +2 -0
  127. package/dist/redis/pubsub-signal.js +2 -2
  128. package/dist/schema-cli.js +4 -14
  129. package/dist/search/meilisearch-adapter.js +17 -6
  130. package/dist/search/purge-subject.js +8 -3
  131. package/dist/stack/drain-jobs.js +24 -12
  132. package/dist/stack/table-helpers.js +10 -8
  133. package/dist/testing/closed-connection-error.js +2 -3
  134. package/dist/testing/wait-for.js +1 -0
  135. package/dist/ui-types/index.d.ts +1 -1
  136. package/dist/ui-types/index.js +1 -1
  137. package/dist/upgrade-cli.d.ts +1 -0
  138. package/dist/upgrade-cli.js +36 -15
  139. package/package.json +4 -4
  140. package/src/changes.json +206 -0
  141. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  142. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  143. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  144. package/src/scripts/codemod/pii-personal-migration.ts +56 -19
  145. package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
  146. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  147. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  148. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  149. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  150. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  151. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  152. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  153. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  154. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  155. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  156. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  157. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -16,7 +16,7 @@ import { isSelfPiiField } from "../crypto/is-self-pii-field.js";
16
16
  import { toSnakeCase } from "../utils/case.js";
17
17
  import { resolveTableName } from "./entity-table-meta.js";
18
18
  import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
19
- import { tableExists } from "./schema-inspection.js";
19
+ import { columnNamesOf, tableExists } from "./schema-inspection.js";
20
20
  export async function nullBlindIndexesForSubject(db, features, subjectKey) {
21
21
  const likePattern = subjectCiphertextLikePattern(subjectKey);
22
22
  for (const feature of features.values()) {
@@ -34,8 +34,14 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
34
34
  // linkable (fw#2550).
35
35
  if (!(await tableExists(db, tableName)))
36
36
  continue;
37
+ // Same hazard for a migrated table that predates a later-added
38
+ // lookupable field: skip fields whose columns are missing instead of
39
+ // aborting the sweep for the remaining ones.
40
+ const columns = await columnNamesOf(db, tableName);
37
41
  for (const fieldName of lookupable) {
38
42
  const snake = toSnakeCase(fieldName);
43
+ if (!columns.has(snake) || !columns.has(`${snake}_bidx`))
44
+ continue;
39
45
  await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
40
46
  }
41
47
  }
@@ -67,6 +73,10 @@ export async function subjectRowExistsInTenant(db, features, subjectId, tenantId
67
73
  const hasSelfPiiField = Object.values(entity.fields).some(isSelfPiiField);
68
74
  if (!hasSelfPiiField)
69
75
  continue;
76
+ // Subject ids are uuids; comparing one to a serial id column makes
77
+ // postgres throw (invalid input syntax for integer) mid forget-TX.
78
+ if (entity.idType === "serial")
79
+ continue;
70
80
  candidateTables.push(resolveTableName(entityName, entity, undefined));
71
81
  }
72
82
  }
@@ -17,7 +17,7 @@
17
17
  // `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
18
18
  import { collectPiiSubjectFields } from "../crypto/index.js";
19
19
  import { SQL_EXPR_BRAND } from "./dialect.js";
20
- import { READ_MODEL_PREFIX, toSnakeCase, toTableName } from "./table-builder.js";
20
+ import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
21
21
  // Standard base columns for event-sourced read-model tables. Mirrors
22
22
  // `buildBaseColumns()` from table-builder.ts (drizzle variant).
23
23
  function fullBaseColumns(idType, softDelete) {
@@ -300,7 +300,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
300
300
  // permanently block reuse; kept in lock-step with table-builder.ts (framework#2593).
301
301
  const whereSql = explicitWhereSql ??
302
302
  (def.where === undefined && def.unique === true && entity.softDelete === true
303
- ? `"is_deleted" = false`
303
+ ? SOFT_DELETE_LIVE_ROW_PREDICATE
304
304
  : undefined);
305
305
  indexes.push({
306
306
  name: indexName,
@@ -322,7 +322,9 @@ export function deriveEntityTableMeta(entityName, entity, options) {
322
322
  // Soft-deleted rows keep their bidx hash — without this, a restored
323
323
  // row can't reuse a value a soft-deleted sibling still holds
324
324
  // (framework#2464). Kept in lock-step with table-builder.ts.
325
- const whereParts = entity.softDelete === true ? [...notNullParts, `"is_deleted" = false`] : notNullParts;
325
+ const whereParts = entity.softDelete === true
326
+ ? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
327
+ : notNullParts;
326
328
  indexes.push({
327
329
  name: `${indexName}_bidx`,
328
330
  columns: bidxCols,
@@ -289,27 +289,21 @@ export function buildExecutorContext(table, entity, options) {
289
289
  ...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
290
290
  ];
291
291
  }
292
- // Combined, atomic read for the `expect:` precondition (#3024): the
293
- // projection row's expect-checked fields AND the events table's current
294
- // MAX(version) for this aggregate, in ONE SQL statement.
292
+ // Combined, atomic read for the `expect:` precondition: the projection
293
+ // row's expect-checked fields AND the events table's current MAX(version)
294
+ // for this aggregate, in ONE SQL statement.
295
295
  //
296
- // Two separate reads (loadById then getStreamVersion, in either order)
297
- // leave a real gap: applyEntityEvent writes the projection in a SEPARATE
298
- // statement AFTER its event commits, so a second reader can see a version
299
- // that already reflects a concurrent writer's event while its OWN read of
300
- // the expect fields still reflects the pre-write projection row — verified
301
- // empirically: ~40% of genuinely concurrent update() calls slipped a stale
302
- // precondition through with two round-trips, regardless of read order. One
303
- // query removes the gap: Postgres executes it against a single consistent
304
- // snapshot.
296
+ // Two separate reads leave a gap: applyEntityEvent writes the projection in
297
+ // a separate statement after its event commits, so a second reader can see
298
+ // a version that already reflects a concurrent writer's event while its read
299
+ // of the expect fields still shows the pre-write row. One query runs against
300
+ // a single consistent snapshot.
305
301
  //
306
- // This also sidesteps a correctness bug the row's own `version` column
307
- // can't be trusted for: applyEntityEvent only keeps row.version in
308
- // lock-step with the OTHER projection columns for rows this executor
309
- // itself wrote. A row seeded directly (raw INSERT — test fixtures, or
310
- // legacy pre-#762 data) can carry a default version (e.g. 1) with ZERO
311
- // matching events. Deriving expectedVersion from such a row.version makes
312
- // the append below target a non-existent predecessor and fail outright.
302
+ // The row's own `version` column can't be trusted instead: it is only in
303
+ // lock-step with the events for rows this executor wrote. A raw-seeded row
304
+ // (test fixtures, legacy data) can carry a default version with zero
305
+ // matching events, so deriving expectedVersion from it makes the append
306
+ // target a non-existent predecessor and fail.
313
307
  // The events table's MAX(version) (0 for such a row) is the only value
314
308
  // append() can safely use as expectedVersion — exactly what
315
309
  // getStreamVersion() already returns for every other (non-`expect`)
@@ -1,6 +1,6 @@
1
1
  import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
3
- import { collectPiiSubjectFields, computeBlindIndex, configuredBlindIndexKey, } from "../crypto/index.js";
3
+ import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
5
  import { coerceRow, extractTableInfo } from "../db/query.js";
6
6
  import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
@@ -10,7 +10,7 @@ import { getStreamVersion } from "../event-store/index.js";
10
10
  import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
11
11
  import { rehydrateCompoundTypes } from "./compound-types.js";
12
12
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
13
- import { collectEncryptedFieldNames, hasSearchablePlaintext, isSensitiveLabelField, } from "./entity-field-encryption.js";
13
+ import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-encryption.js";
14
14
  import { buildFilterWhere, } from "./event-store-executor-context.js";
15
15
  import { buildParentRefClause } from "./parent-ref-clause.js";
16
16
  import { buildEntityTable, physicalColumnName } from "./table-builder.js";
@@ -105,20 +105,29 @@ function applyMultiSelectFilter(colSql, whereSql, params, f) {
105
105
  return;
106
106
  }
107
107
  const parts = f.value.map((v) => {
108
- params.push([v]);
109
- return `${colSql(f.field)} @> $${params.length}::jsonb`;
108
+ params.push(JSON.stringify([v]));
109
+ return `${colSql(f.field)} @> $${params.length}::text::jsonb`;
110
110
  });
111
111
  whereSql.push(`(${parts.join(" OR ")})`);
112
112
  // skip: containment condition already pushed — don't fall through to the eq/ne branch below
113
113
  return;
114
114
  }
115
- // Bind a JS array, not JSON.stringify(value) — postgres.js double-encodes
116
- // a stringified array through an `::jsonb` cast, so `@>` would never match
117
- // (see update-roles.ts:67-68). An array value means "contains all of
118
- // these" (the natural `@>` reading).
119
- params.push(Array.isArray(f.value) ? f.value : [f.value]);
120
- const containment = `${colSql(f.field)} @> $${params.length}::jsonb`;
121
- whereSql.push(f.op === "ne" ? `NOT (${containment})` : containment);
115
+ const candidates = Array.isArray(f.value) ? f.value : [f.value];
116
+ // `@> '[]'` is true for every non-null row, so an empty array would turn
117
+ // the filter into a silent match-all.
118
+ if (candidates.length === 0) {
119
+ whereSql.push("FALSE");
120
+ // skip: empty candidate list is unsatisfiable
121
+ return;
122
+ }
123
+ // Bound as text then cast: a JS array makes postgres.js infer boolean[]/int4[]
124
+ // for client-supplied non-string values, and `boolean[]::jsonb` is rejected
125
+ // (500 on client input). An array value means "contains all of these".
126
+ params.push(JSON.stringify(candidates));
127
+ const containment = `${colSql(f.field)} @> $${params.length}::text::jsonb`;
128
+ // A NULL column must count as "does not contain" — plain NOT(...) yields
129
+ // NULL and would drop those rows.
130
+ whereSql.push(f.op === "ne" ? `(${colSql(f.field)} IS NULL OR NOT (${containment}))` : containment);
122
131
  }
123
132
  // Falls through to the screen-filter WHERE builder shared with
124
133
  // screen-declared filters — buildFilterWhere flattens the op into a
@@ -308,7 +317,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
308
317
  // `name` is a declared field on the tenant entity, not an id/row-meta column
309
318
  return undefined;
310
319
  }
311
- const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
320
+ const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
312
321
  const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
313
322
  if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
314
323
  return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetTable, targetTableName, db);
@@ -320,6 +329,17 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
320
329
  }
321
330
  return resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db, searchAdapter);
322
331
  }
332
+ // Building the table is pure but not free, and runs on every search request;
333
+ // keyed by the definition object so a re-registered entity never sees a stale table.
334
+ const referenceTargetTables = new WeakMap();
335
+ function referenceTargetTable(name, entity) {
336
+ const cached = referenceTargetTables.get(entity);
337
+ if (cached?.name === name)
338
+ return cached.table;
339
+ const table = buildEntityTable(name, entity);
340
+ referenceTargetTables.set(entity, { name, table });
341
+ return table;
342
+ }
323
343
  // Projected alongside the row when a reference sort is active, so the keyset
324
344
  // cursor carries the label that was ordered by instead of the FK's UUID.
325
345
  const SORT_LABEL_ALIAS = "__kumiko_sort_label";
@@ -342,8 +362,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
342
362
  // skip: labelField isn't a real (declared) column on the target entity
343
363
  return undefined;
344
364
  }
345
- if (collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
346
- collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)) {
365
+ if (isSensitiveLabelField(targetEntity, descriptor.labelField)) {
347
366
  // skip: encrypted/PII labelField — ciphertext orders by its bytes, which is
348
367
  // arbitrary to the reader and would also rank rows by their stored secret
349
368
  return undefined;
@@ -389,10 +408,6 @@ export function createReadVerbs(ctx) {
389
408
  return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
390
409
  }
391
410
  let filterIds;
392
- // fw#2660 — true once a searchable-reference or row-meta-reference
393
- // clause contributed rows, so the total-count fast-path below knows
394
- // `filterIds.length` alone would undercount.
395
- let referenceClauseActive = false;
396
411
  // Build the WHERE clause as raw SQL — ownership produces a
397
412
  // parameterised fragment that we splice in alongside simple WhereObject
398
413
  // conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
@@ -461,17 +476,12 @@ export function createReadVerbs(ctx) {
461
476
  return `$${params.length}`;
462
477
  });
463
478
  orParts.push(`${colSql(match.ownColumn)} IN (${placeholders.join(", ")})`);
464
- referenceClauseActive = true;
465
479
  }
466
480
  // Parenthesized as one whereSql entry — whereSql is joined with
467
481
  // " AND " below, an unparenthesized OR here would leak past the
468
482
  // tenant filter that follows.
469
483
  whereSql.push(`(${orParts.join(" OR ")})`);
470
484
  }
471
- // The total-count fast-path below is only valid while this is still
472
- // true after every later clause (tenant, soft-delete, cursor,
473
- // ownership, parent-ref, filters) — see where it's checked.
474
- const whereSqlLenAfterSearch = whereSql.length;
475
485
  const sortCandidate = payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
476
486
  ? payload.sort
477
487
  : undefined;
@@ -608,21 +618,14 @@ export function createReadVerbs(ctx) {
608
618
  // total: extra COUNT(*) — only when explicitly requested (pager UI).
609
619
  // Postgres cost is O(table-scan) without a filter, with a filter as
610
620
  // expensive as the corresponding WHERE — cheap enough on indexed columns.
611
- // On the search path, `total = filterIds.length` needs no extra query —
612
- // unless a reference match contributed additional rows (fw#2660), or the
613
- // WHERE grew past the search block (ownership, parent-ref, filters,
614
- // soft-delete, cursor all splice in afterwards), in which case the raw
615
- // filterIds length alone would undercount.
621
+ // Always counted over the full WHERE: search ids, reference matches, tenant,
622
+ // soft-delete, cursor, ownership and filters all narrow the result, so the
623
+ // search hit count alone is never the total.
616
624
  let total;
617
625
  if (totalCount) {
618
- if (filterIds && !referenceClauseActive && whereSql.length === whereSqlLenAfterSearch) {
619
- total = filterIds.length;
620
- }
621
- else {
622
- const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
623
- const countRows = await executeRawQueryRead(runner, countSql, whereParams);
624
- total = countRows[0]?.count ?? 0;
625
- }
626
+ const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
627
+ const countRows = await executeRawQueryRead(runner, countSql, whereParams);
628
+ total = countRows[0]?.count ?? 0;
626
629
  }
627
630
  return { rows, nextCursor, ...(total !== undefined && { total }) };
628
631
  },
@@ -66,13 +66,13 @@ export function createWriteVerbs(ctx) {
66
66
  const entityInstructionFieldNames = instructionFieldNames(entity);
67
67
  return {
68
68
  async create(payload, user, db, options) {
69
- const runner = tenantDbRunner(db);
70
69
  if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
71
70
  throw new AccessDeniedError({
72
71
  message: `${entityName}.create: entity is tenancy: "global" — payload.tenantId must be ` +
73
72
  "SYSTEM_TENANT_ID or omitted.",
74
73
  });
75
74
  }
75
+ const runner = tenantDbRunner(db);
76
76
  // Respect an explicit id in the payload (seed pattern, SCIM import). Without
77
77
  // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
78
78
  // event payload so defaults + downstream consumers don't see a redundant id field.
@@ -304,20 +304,8 @@ export function createWriteVerbs(ctx) {
304
304
  // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
305
305
  // with version_conflict.
306
306
  //
307
- // `expect:` (#3024) reads this same authoritative version, but through
308
- // loadExpectSnapshot's single combined query instead of a second,
309
- // separate getStreamVersion() round-trip: two reads (in either order)
310
- // leave a real gap — applyEntityEvent writes the projection in a
311
- // SEPARATE statement after its event commits, so a second reader's
312
- // version-read can land in that gap and see a fresh, non-conflicting
313
- // version paired with a still-stale projection row (verified
314
- // empirically: ~40% of genuinely concurrent runs slipped through with
315
- // two round-trips). One query removes the gap. It also avoids trusting
316
- // the row's own `version` column for the expectedVersion: that column
317
- // is only in lock-step with the rest of the row for rows THIS executor
318
- // wrote — a raw-seeded row (test fixture, or legacy pre-#762 data) can
319
- // carry a default version with zero matching events, which would make
320
- // the append below target a non-existent predecessor and fail outright.
307
+ // With `expect:` the version comes from loadExpectSnapshot's combined
308
+ // query instead; see its comment for why.
321
309
  let currentVersion;
322
310
  if (updateOptions?.expect) {
323
311
  const expectKeys = Object.keys(updateOptions.expect);
@@ -603,12 +591,12 @@ export function createWriteVerbs(ctx) {
603
591
  };
604
592
  },
605
593
  async restore(payload, user, db) {
606
- const runner = tenantDbRunner(db);
607
594
  if (!softDelete) {
608
595
  return writeFailure(new UnprocessableError("soft_delete_not_enabled", {
609
596
  i18nKey: "errors.softDeleteNotEnabled",
610
597
  }));
611
598
  }
599
+ const runner = tenantDbRunner(db);
612
600
  // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
613
601
  // selectMany(runner, ...) did not — any caller could un-delete a foreign
614
602
  // tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
@@ -672,7 +660,17 @@ export function createWriteVerbs(ctx) {
672
660
  // Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
673
661
  // plus the version bump, so there is no sensitive-field drift and no
674
662
  // payload override is needed.
675
- const restoreResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
663
+ // Soft-delete unique indexes are partial (WHERE is_deleted = false), so a live row may have re-taken the value this restore brings back.
664
+ let restoreResult;
665
+ try {
666
+ restoreResult = await runInSavepointIfSupported(runner, async (sp) => applyEntityEvent(event, table, entity, sp));
667
+ }
668
+ catch (e) {
669
+ const mapped = tryMapUniqueViolation(e, entityName);
670
+ if (mapped)
671
+ return mapped;
672
+ throw e;
673
+ }
676
674
  if (restoreResult.kind !== "applied" || restoreResult.row === null) {
677
675
  return writeFailure(new InternalError({ message: "projection restore returned no row" }));
678
676
  }
@@ -115,34 +115,25 @@ function diffOneTable(prev, next) {
115
115
  newIndexes.push(idx);
116
116
  continue;
117
117
  }
118
- const change = { name };
119
- if (!indexColumnsEqual(prevI.columns, idx.columns)) {
120
- Object.assign(change, {
121
- columnsChanged: { from: prevI.columns, to: idx.columns },
122
- });
123
- }
124
- if ((prevI.unique ?? false) !== (idx.unique ?? false)) {
125
- Object.assign(change, {
126
- uniqueChanged: { from: prevI.unique ?? false, to: idx.unique ?? false },
127
- });
128
- }
129
- if (prevI.whereSql !== idx.whereSql) {
130
- Object.assign(change, {
131
- whereSqlChanged: { from: prevI.whereSql, to: idx.whereSql },
132
- });
133
- }
134
- if ((prevI.needsManualWhere ?? false) !== (idx.needsManualWhere ?? false)) {
135
- Object.assign(change, {
136
- needsManualWhereChanged: {
137
- from: prevI.needsManualWhere ?? false,
138
- to: idx.needsManualWhere ?? false,
139
- },
140
- });
141
- }
142
- if (change.columnsChanged ||
143
- change.uniqueChanged ||
144
- change.whereSqlChanged ||
145
- change.needsManualWhereChanged) {
118
+ const columnsChanged = indexColumnsEqual(prevI.columns, idx.columns)
119
+ ? undefined
120
+ : { from: prevI.columns, to: idx.columns };
121
+ const uniqueChanged = (prevI.unique ?? false) === (idx.unique ?? false)
122
+ ? undefined
123
+ : { from: prevI.unique ?? false, to: idx.unique ?? false };
124
+ const whereSqlChanged = prevI.whereSql === idx.whereSql ? undefined : { from: prevI.whereSql, to: idx.whereSql };
125
+ const needsManualWhereChanged = (prevI.needsManualWhere ?? false) === (idx.needsManualWhere ?? false)
126
+ ? undefined
127
+ : { from: prevI.needsManualWhere ?? false, to: idx.needsManualWhere ?? false };
128
+ if (columnsChanged || uniqueChanged || whereSqlChanged || needsManualWhereChanged) {
129
+ // Single literal typed as IndexChange so a misspelled key fails to compile instead of silently dropping the change.
130
+ const change = {
131
+ name,
132
+ ...(columnsChanged && { columnsChanged }),
133
+ ...(uniqueChanged && { uniqueChanged }),
134
+ ...(whereSqlChanged && { whereSqlChanged }),
135
+ ...(needsManualWhereChanged && { needsManualWhereChanged }),
136
+ };
146
137
  changedIndexes.push(change);
147
138
  }
148
139
  }
@@ -215,6 +206,14 @@ function describeRecreateReasons(td) {
215
206
  if (newUniqueIndexes.length > 0) {
216
207
  reasons.push(`new UNIQUE index(es): ${newUniqueIndexes.join(", ")}`);
217
208
  }
209
+ // A changed index that ends up UNIQUE (newly unique, new columns, widened WHERE) can fail on existing duplicates in place.
210
+ const uniqueNextNames = new Set(td.nextMeta.indexes.filter((idx) => idx.unique === true).map((idx) => idx.name));
211
+ const changedToUnique = td.changedIndexes
212
+ .filter((c) => uniqueNextNames.has(c.name))
213
+ .map((c) => c.name);
214
+ if (changedToUnique.length > 0) {
215
+ reasons.push(`changed UNIQUE index(es): ${changedToUnique.join(", ")}`);
216
+ }
218
217
  const madeNotNull = td.changedColumns
219
218
  .filter((c) => c.nullabilityChanged?.to === true)
220
219
  .map((c) => c.name);
@@ -4,14 +4,18 @@
4
4
  // error. Used by the event-store to distinguish a unique-violation on the
5
5
  // aggregate-version index (optimistic-concurrency conflict) from the one on
6
6
  // the idempotency-key index (caller-side replay signal).
7
+ const MAX_CAUSE_DEPTH = 8;
7
8
  export function extractPgError(e) {
8
9
  if (typeof e !== "object" || e === null)
9
10
  return null;
10
- const layers = [e];
11
- // @cast-boundary error-details — DrizzleQueryError wraps PG-error in .cause
12
- const cause = e.cause;
13
- if (typeof cause === "object" && cause !== null)
14
- layers.push(cause);
11
+ const layers = [];
12
+ let current = e;
13
+ // Depth cap doubles as cycle protection for self-referential causes.
14
+ while (typeof current === "object" && current !== null && layers.length < MAX_CAUSE_DEPTH) {
15
+ layers.push(current);
16
+ // @cast-boundary error-details — DrizzleQueryError wraps PG-error in .cause
17
+ current = current.cause;
18
+ }
15
19
  for (const layer of layers) {
16
20
  // @cast-boundary error-details — postgres-js error shape (code, constraint_name)
17
21
  const code = layer.code;
@@ -249,11 +249,21 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
249
249
  const catalogFields = eventCatalog.get(row.type);
250
250
  if (catalogFields) {
251
251
  for (const [field, spec] of Object.entries(catalogFields)) {
252
- const subject = resolveEventSubject(field, spec, payload, {
253
- tenantId: row.tenant_id, // @cast-boundary db-read — tenant_id column is the branded TenantId
254
- aggregateType: row.aggregate_type,
255
- aggregateId: row.aggregate_id,
256
- });
252
+ let subject;
253
+ try {
254
+ subject = resolveEventSubject(field, spec, payload, {
255
+ tenantId: row.tenant_id, // @cast-boundary db-read — tenant_id column is the branded TenantId
256
+ aggregateType: row.aggregate_type,
257
+ aggregateId: row.aggregate_id,
258
+ });
259
+ }
260
+ catch (e) {
261
+ // Re-thrown as a plain Error: the owner-resolution retry hint added for
262
+ // SubjectResolutionError only applies to entity events.
263
+ if (!(e instanceof SubjectResolutionError))
264
+ throw e;
265
+ throw new Error(`catalog event subject unresolvable: ${e.message} (fix aggregate_type/tenant_id or the piiFields declaration)`);
266
+ }
257
267
  if (subject === null)
258
268
  continue;
259
269
  const outcome = await encryptField(payload, field, subject);
@@ -109,8 +109,8 @@ export async function assertLiveTableHasNoRowLevelSecurity(tx, tableName, projec
109
109
  throw new Error(`${context}: live table "${tableName}" has row level security (enabled: ${row.rls_enabled}, ` +
110
110
  `forced: ${row.rls_forced}, policies: ${row.policy_count}). The shadow swap rebuilds the table ` +
111
111
  "from EntityTableMeta and would silently drop RLS and every policy. Kumiko does not support RLS " +
112
- "on rebuildable tables — see kumiko-platform docs/plans/rls-evaluation.md. Rebuild aborted; live " +
113
- "table untouched.");
112
+ "on rebuildable tables: drop the RLS flags/policies on this table or exclude it from online " +
113
+ "rebuild. Rebuild aborted; live table untouched.");
114
114
  }
115
115
  // Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
116
116
  // Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
@@ -227,8 +227,9 @@ export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName
227
227
  throw new Error(`projection-rebuild "${projectionName}": "${tableName}" has ${count} row(s) with a populated ` +
228
228
  `blind-index column, but KUMIKO_BLIND_INDEX_KEY is not configured in this process. The rebuild ` +
229
229
  `would recompute those columns to NULL, and equality lookups on that field (login, password ` +
230
- `reset) would stop matching afterward. Configure the blind-index key before this apply/rebuild ` +
231
- `runs. See fw#3091. Rebuild aborted; live table untouched.`);
230
+ `reset) would stop matching afterward. Configure the complete KMS wiring (PLATFORM_KEK, ` +
231
+ `SUBJECT_KEYS_DATABASE_URL, KUMIKO_BLIND_INDEX_KEY, all-or-none) in the process running this ` +
232
+ `apply/rebuild. See fw#3091. Rebuild aborted; live table untouched.`);
232
233
  }
233
234
  // Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
234
235
  // live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
@@ -5,6 +5,7 @@ import type { EntityDefinition, EntityRelations, FieldDefinition, FieldsMap } fr
5
5
  import type { Table } from "./event-store-executor-context.js";
6
6
  export { toSnakeCase } from "../utils/case.js";
7
7
  import { type ColumnBuilder, type ColumnHandle, type SchemaTable, type TableColumns } from "./dialect.js";
8
+ export declare const SOFT_DELETE_LIVE_ROW_PREDICATE = "\"is_deleted\" = false";
8
9
  export declare function physicalColumnName(table: Table, field: string): string;
9
10
  export type AnyPgColumn = ColumnHandle;
10
11
  export declare const READ_MODEL_PREFIX = "read_";
@@ -2,7 +2,10 @@ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
2
2
  import { toSnakeCase } from "../utils/case.js";
3
3
  import { assertUnreachable } from "../utils/index.js";
4
4
  export { toSnakeCase } from "../utils/case.js";
5
- import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, SQL_EXPR_BRAND, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
5
+ import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
6
+ // Partial-index predicate for soft-delete entities; table-builder and
7
+ // entity-table-meta must render identical index definitions.
8
+ export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
6
9
  // Shared by the CRUD executor and the parent-ref read-gate — a field's
7
10
  // declared column name if it has one, else its snake_case default.
8
11
  export function physicalColumnName(table, field) {
@@ -382,13 +385,7 @@ export function buildEntityTable(entityName, entity, options) {
382
385
  else if (def.unique === true && entity.softDelete === true) {
383
386
  // Soft-deleted rows keep their value, so a full unique index would
384
387
  // permanently block reuse; kept in lock-step with entity-table-meta.ts (framework#2593).
385
- const softDeleteWhere = {
386
- kind: "sql-expr",
387
- text: `"is_deleted" = false`,
388
- params: [],
389
- [SQL_EXPR_BRAND]: true,
390
- };
391
- chain = chain.where(softDeleteWhere);
388
+ chain = chain.where(sql.raw(SOFT_DELETE_LIVE_ROW_PREDICATE));
392
389
  }
393
390
  indexes[indexName] = chain;
394
391
  // Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
@@ -407,14 +404,9 @@ export function buildEntityTable(entityName, entity, options) {
407
404
  // restored row can't reuse a value a soft-deleted sibling still
408
405
  // holds (framework#2464).
409
406
  const whereText = (entity.softDelete === true
410
- ? [...notNullParts, `"is_deleted" = false`]
407
+ ? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
411
408
  : notNullParts).join(" AND ");
412
- const partialWhere = {
413
- kind: "sql-expr",
414
- text: whereText,
415
- params: [],
416
- [SQL_EXPR_BRAND]: true,
417
- };
409
+ const partialWhere = sql.raw(whereText);
418
410
  indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
419
411
  .on(...bidxCols)
420
412
  .where(partialWhere);
@@ -3,8 +3,7 @@ import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-t
3
3
  import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
4
4
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
5
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
6
- import { fallbackEscapeHatchReporter } from "../observability/escape-hatch-report.js";
7
- import { emitDbQuery, registerStandardMetrics, } from "../observability/index.js";
6
+ import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
8
7
  import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
9
8
  export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
10
9
  const declaredUnsafeRawRunners = new WeakMap();
@@ -361,6 +360,17 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
361
360
  return undefined;
362
361
  return new AccessDeniedError({ message });
363
362
  }
363
+ // A tenant-mode write against a "global" table can never match (rows carry SYSTEM_TENANT_ID, writeWhere pins the caller's tenant), so reject loudly instead of silently affecting zero rows.
364
+ function tenantWriteOnGlobalTable(table, method) {
365
+ if (mode !== "tenant" || !hasTenantColumn(table))
366
+ return undefined;
367
+ if (asEntityTableMeta(table)?.tenancy !== "global")
368
+ return undefined;
369
+ return new AccessDeniedError({
370
+ message: `${method}(${tableNameOf(table)}): "global" table rows carry SYSTEM_TENANT_ID and never match the caller's tenant; ` +
371
+ "use db.global(table) with escapeHatch instead.",
372
+ });
373
+ }
364
374
  function globalTable(table) {
365
375
  const meta = asEntityTableMeta(table);
366
376
  if (meta?.tenancy !== "global") {
@@ -466,6 +476,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
466
476
  if (!where || Object.keys(where).length === 0) {
467
477
  return Promise.reject(new Error("TenantDb.updateMany without where would mass-update all tenant rows. Pass at least one where condition."));
468
478
  }
479
+ const globalDenied = tenantWriteOnGlobalTable(table, "updateMany");
480
+ if (globalDenied)
481
+ return Promise.reject(globalDenied);
469
482
  const personalDenied = personalDataDenied(table, Object.keys(set));
470
483
  if (personalDenied)
471
484
  return Promise.reject(personalDenied);
@@ -476,6 +489,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
476
489
  if (!where || Object.keys(where).length === 0) {
477
490
  return Promise.reject(new Error("TenantDb.deleteMany without where would mass-delete all tenant rows. Pass at least one where condition."));
478
491
  }
492
+ const globalDenied = tenantWriteOnGlobalTable(table, "deleteMany");
493
+ if (globalDenied)
494
+ return Promise.reject(globalDenied);
479
495
  const filter = writeWhere(table, where);
480
496
  return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
481
497
  },
@@ -34,7 +34,9 @@ export function variantSuffix(name, spec) {
34
34
  // A full derivative-suffix segment is `<name>-<16 hex chars>` — mirrors
35
35
  // VARIANT_NAME_PATTERN (name grammar) + specHash's fixed 16-char slice.
36
36
  // Keep in sync with both if either changes.
37
- const DERIVATIVE_SUFFIX_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}-[0-9a-f]{16}$/i;
37
+ // The hash part stays case-sensitive (specHash emits lowercase hex only), so
38
+ // the erasure sweep never matches a differently-cased sibling file.
39
+ const DERIVATIVE_SUFFIX_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}-[0-9a-f]{16}$/;
38
40
  // Mirrors deriveKey's own split so callers get the exact same base/ext this
39
41
  // key's derivatives were built from.
40
42
  function splitKey(key) {