@cosmicdrift/kumiko-framework 0.334.0 → 0.336.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
@@ -34,12 +34,12 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
34
34
  signal: AbortSignal.timeout(DECRYPT_TIMEOUT_MS),
35
35
  });
36
36
  }
37
- catch {
37
+ catch (error) {
38
38
  if (attempt < maxAttempts) {
39
39
  await sleep(RETRY_DELAYS_MS[attempt - 1] ?? 0);
40
40
  continue;
41
41
  }
42
- throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`);
42
+ throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: network error or timeout`, { cause: error });
43
43
  }
44
44
  if (!response.ok) {
45
45
  if (isRetryableStatus(response.status) && attempt < maxAttempts) {
@@ -62,60 +62,64 @@ async function decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, lo
62
62
  }
63
63
  throw new Error(`${prefix}Key Manager decrypt failed for key ${keyId}: retries exhausted`);
64
64
  }
65
+ // A leftover plaintext beside a ciphertext boots green while nothing was
66
+ // migrated, which is indistinguishable from a finished cutover unless the
67
+ // boot says which source won. The line is built from the path actually taken.
65
68
  async function resolveSlot(name, env, options, fetchImpl) {
66
69
  const plaintext = env[name];
67
- if (plaintext)
68
- return plaintext;
69
70
  const ciphertext = env[`${name}_CIPHERTEXT`];
71
+ if (plaintext) {
72
+ return {
73
+ value: plaintext,
74
+ sourceLine: ciphertext
75
+ ? `${name} source=plaintext-env (ciphertext present and ignored)`
76
+ : `${name} source=plaintext-env`,
77
+ };
78
+ }
70
79
  if (!ciphertext)
71
- return undefined;
80
+ return { value: undefined, sourceLine: undefined };
72
81
  const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
73
82
  const token = env.PLATFORM_KEK_KMS_TOKEN;
74
83
  if (!keyId || !token) {
75
84
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
76
- throw new Error(`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext (slot ${name}) — a partial set means the KMS wiring is broken.`);
77
- }
78
- const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
79
- return decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
80
- }
81
- // A leftover plaintext beside a ciphertext boots green while nothing was
82
- // migrated, which is indistinguishable from a finished cutover unless the
83
- // boot says which source won. Never carries a key value, only its origin.
84
- function describeKekSource(name, env) {
85
- const plaintext = env[name];
86
- const ciphertext = env[`${name}_CIPHERTEXT`];
87
- if (plaintext) {
88
- return ciphertext
89
- ? `${name} source=plaintext-env (ciphertext present and ignored)`
90
- : `${name} source=plaintext-env`;
85
+ throw new Error(`${prefix}${name}_CIPHERTEXT is set but PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are missing — a partial set means the KMS wiring is broken.`);
91
86
  }
92
- if (!ciphertext)
93
- return undefined;
94
87
  const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
95
- return `${name} source=key-manager keyId=${env.PLATFORM_KEK_KMS_KEY_ID} region=${region}`;
88
+ const value = await decryptCiphertext(ciphertext, keyId, token, region, fetchImpl, options.logPrefix);
89
+ return {
90
+ value,
91
+ sourceLine: `${name} source=key-manager keyId=${keyId} region=${region}`,
92
+ };
96
93
  }
97
94
  // Each slot resolves independently so a rollback that clears one slot's
98
95
  // plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
99
96
  // another slot's fallback path — the trio check downstream still applies.
97
+ // Slots resolve in parallel: sequentially, a slow Key Manager would cost up to
98
+ // ~16 s per slot (3 x 5 s timeout + backoff) and could trip startup probes.
100
99
  export async function resolvePlatformKeks(env, options = {}) {
101
100
  const fetchImpl = options.fetch ?? globalThis.fetch;
101
+ const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
102
102
  const slots = options.slots ?? LEGACY_SLOTS;
103
- const resolved = {};
104
- for (const name of slots) {
105
- resolved[name] = await resolveSlot(name, env, options, fetchImpl);
103
+ // Misconfiguration must not cost a Key Manager round trip.
104
+ if (slots.includes("PLATFORM_KEK_PREVIOUS") &&
105
+ (env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT) &&
106
+ !env.PLATFORM_KEK_PREVIOUS_VERSION) {
107
+ throw new Error(`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`);
106
108
  }
107
- const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
109
+ const results = await Promise.all(slots.map((name) => resolveSlot(name, env, options, fetchImpl)));
108
110
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
109
111
  const log = options.log ?? console.info;
110
- for (const name of slots) {
111
- const line = describeKekSource(name, env);
112
- if (line)
113
- log(`${prefix}${line}`);
114
- }
115
- if (resolved["PLATFORM_KEK_PREVIOUS"] && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
116
- throw new Error(`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`);
117
- }
118
- const changed = slots.some((name) => resolved[name] !== env[name]);
112
+ const resolved = {};
113
+ slots.forEach((name, index) => {
114
+ const result = results[index];
115
+ // skip: slot without a resolved result stays unset
116
+ if (!result)
117
+ return;
118
+ resolved[name] = result.value;
119
+ if (result.sourceLine)
120
+ log(`${prefix}${result.sourceLine}`);
121
+ });
122
+ const changed = Object.keys(resolved).some((name) => resolved[name] !== env[name]);
119
123
  if (!changed)
120
124
  return env;
121
125
  return { ...env, ...resolved };
@@ -3,8 +3,8 @@ import * as z from "zod";
3
3
  export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
4
4
  // The KMS error classes live here and not in kumiko-types (#1629): callers
5
5
  // branch on them with `instanceof`, which needs a single copy of the class.
6
- // `satisfies z.ZodType<SubjectId>` binds this to the TS type at compile time
7
- // (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
6
+ // `satisfies z.ZodType<SubjectId>` plus the reverse check in subject-id-schema.test.ts bind this to the TS type
7
+ // at compile time (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
8
8
  // longer drift apart on which subjects are valid, the way #2809 happened.
9
9
  export const subjectIdSchema = z.discriminatedUnion("kind", [
10
10
  z.object({ kind: z.literal("user"), userId: z.uuid() }),
@@ -114,8 +114,14 @@ export async function applyEntityEvent(event, table, entity, tx) {
114
114
  // UPDATE so Postgres doesn't error with "column X does not exist".
115
115
  const changes = {};
116
116
  for (const [key, value] of Object.entries(rawChanges)) {
117
- const fieldType = entity.fields[key]?.type;
118
- if (fieldType === "files" || fieldType === "images")
117
+ const field = entity.fields[key];
118
+ // Historical events can carry fields since removed from the entity; the
119
+ // tolerance lives here (not in updateMany) so a live typo still throws.
120
+ // Base/soft-delete columns (insertedById, deletedAt, ...) are table
121
+ // columns without an entity field and must still be applied.
122
+ if (field === undefined && !(key in table))
123
+ continue;
124
+ if (field?.type === "files" || field?.type === "images")
119
125
  continue;
120
126
  changes[key] = value;
121
127
  }
@@ -16,9 +16,10 @@ 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
+ const candidates = [];
22
23
  for (const feature of features.values()) {
23
24
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
24
25
  const lookupable = collectLookupableFields(entity);
@@ -27,20 +28,36 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
27
28
  // No featureName prefix — the dispatcher builds entity tables without
28
29
  // one (buildEntityTable with no featureName option), the sweep has to
29
30
  // hit the same names.
30
- const tableName = resolveTableName(entityName, entity, undefined);
31
- // Skip entities whose projection was never migrated — same class as
32
- // subjectRowExistsInTenant (fw#2348). A throw here runs AFTER eraseKey
33
- // in forget-subject and would leave deterministic bidx columns still
34
- // linkable (fw#2550).
35
- if (!(await tableExists(db, tableName)))
31
+ candidates.push({ tableName: resolveTableName(entityName, entity, undefined), lookupable });
32
+ }
33
+ }
34
+ // Skip entities whose projection was never migrated — same class as
35
+ // subjectRowExistsInTenant (fw#2348). A throw here runs AFTER eraseKey
36
+ // in forget-subject and would leave deterministic bidx columns still
37
+ // linkable (fw#2550). One batched existence query instead of one
38
+ // roundtrip per entity.
39
+ const existing = await existingTableNames(db, candidates.map((c) => c.tableName));
40
+ for (const { tableName, lookupable } of candidates) {
41
+ if (!existing.has(tableName))
42
+ continue;
43
+ // Same hazard for a migrated table that predates a later-added
44
+ // lookupable field: skip fields whose columns are missing instead of
45
+ // aborting the sweep for the remaining ones.
46
+ const columns = await columnNamesOf(db, tableName);
47
+ for (const fieldName of lookupable) {
48
+ const snake = toSnakeCase(fieldName);
49
+ if (!columns.has(snake) || !columns.has(`${snake}_bidx`))
36
50
  continue;
37
- for (const fieldName of lookupable) {
38
- const snake = toSnakeCase(fieldName);
39
- await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
40
- }
51
+ await executeRawQuery(db, `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(`${snake}_bidx`)} = NULL WHERE ${quoteIdent(snake)} LIKE $1`, [likePattern]);
41
52
  }
42
53
  }
43
54
  }
55
+ async function existingTableNames(db, tableNames) {
56
+ if (tableNames.length === 0)
57
+ return new Set();
58
+ const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass(quote_ident(t.name)) IS NOT NULL`, [tableNames]);
59
+ return new Set(rows.map((r) => r.name));
60
+ }
44
61
  // Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
45
62
  // "user"-kind subject id is often not a real user (share-token recipient,
46
63
  // email subscriber, ...) — those entities self-own their PII (`pii: true`,
@@ -67,6 +84,10 @@ export async function subjectRowExistsInTenant(db, features, subjectId, tenantId
67
84
  const hasSelfPiiField = Object.values(entity.fields).some(isSelfPiiField);
68
85
  if (!hasSelfPiiField)
69
86
  continue;
87
+ // Subject ids are uuids; comparing one to a serial id column makes
88
+ // postgres throw (invalid input syntax for integer) mid forget-TX.
89
+ if (entity.idType === "serial")
90
+ continue;
70
91
  candidateTables.push(resolveTableName(entityName, entity, undefined));
71
92
  }
72
93
  }
@@ -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;
@@ -558,8 +568,12 @@ export function createReadVerbs(ctx) {
558
568
  : referenceSortExpr !== undefined
559
569
  ? referenceSortExpr(params)
560
570
  : colSql(sortField);
561
- const orderByClause = sortSql !== undefined
562
- ? ` ORDER BY ${sortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
571
+ // The projected label alias is ordered by directly, so Postgres evaluates
572
+ // the correlated subquery once per row instead of once for ORDER BY and
573
+ // once for the SELECT list.
574
+ const orderBySortSql = referenceSortExpr !== undefined ? `"${SORT_LABEL_ALIAS}"` : sortSql;
575
+ const orderByClause = orderBySortSql !== undefined
576
+ ? ` ORDER BY ${orderBySortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
563
577
  : ` ORDER BY ${colSql("id")} ASC`;
564
578
  const useOffset = !payload.cursor && offset > 0;
565
579
  const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
@@ -608,21 +622,14 @@ export function createReadVerbs(ctx) {
608
622
  // total: extra COUNT(*) — only when explicitly requested (pager UI).
609
623
  // Postgres cost is O(table-scan) without a filter, with a filter as
610
624
  // 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.
625
+ // Always counted over the full WHERE: search ids, reference matches, tenant,
626
+ // soft-delete, cursor, ownership and filters all narrow the result, so the
627
+ // search hit count alone is never the total.
616
628
  let total;
617
629
  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
- }
630
+ const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
631
+ const countRows = await executeRawQueryRead(runner, countSql, whereParams);
632
+ total = countRows[0]?.count ?? 0;
626
633
  }
627
634
  return { rows, nextCursor, ...(total !== undefined && { total }) };
628
635
  },
@@ -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;