@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -85,6 +85,7 @@ export type SqlExpression = {
85
85
  readonly params: readonly unknown[];
86
86
  readonly [SQL_EXPR_BRAND]: true;
87
87
  };
88
+ export declare function isSqlExpression(value: unknown): value is SqlExpression;
88
89
  export declare function sql(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlExpression;
89
90
  export declare namespace sql {
90
91
  var raw: (text: string) => SqlExpression;
@@ -18,6 +18,7 @@
18
18
  // crashed on timestamptz writes without it (#1480).
19
19
  import { KUMIKO_COLUMNS_SYMBOL, KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
20
20
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
21
+ import { schemaTableName } from "./table-introspection.js";
21
22
  function isNumericPgType(t) {
22
23
  return t.startsWith("numeric(");
23
24
  }
@@ -76,9 +77,8 @@ function buildColumn(sqlName, pgType, opts) {
76
77
  return value ? "true" : "false";
77
78
  if (typeof value === "bigint")
78
79
  return value.toString();
79
- if (value && typeof value === "object" && SQL_EXPR_BRAND in value) {
80
+ if (isSqlExpression(value))
80
81
  return value.text;
81
- }
82
82
  if (typeof value === "function")
83
83
  return null; // function-defaults stay JS-side
84
84
  // Object/array → jsonb literal
@@ -256,6 +256,13 @@ export function primaryKey(opts) {
256
256
  // `{ kind: "sql-expr" }` from a client-controlled schema definition is never
257
257
  // spliced into SQL text anywhere.
258
258
  export const SQL_EXPR_BRAND = Symbol("sql-expr");
259
+ export function isSqlExpression(value) {
260
+ return (typeof value === "object" &&
261
+ value !== null &&
262
+ SQL_EXPR_BRAND in value &&
263
+ "text" in value &&
264
+ typeof value.text === "string");
265
+ }
259
266
  export function sql(strings, ...values) {
260
267
  const parts = [];
261
268
  const params = [];
@@ -263,7 +270,7 @@ export function sql(strings, ...values) {
263
270
  parts.push(strings[i] ?? "");
264
271
  if (i < values.length) {
265
272
  const v = values[i];
266
- if (v && typeof v === "object" && SQL_EXPR_BRAND in v) {
273
+ if (isSqlExpression(v)) {
267
274
  parts.push(v.text);
268
275
  }
269
276
  else {
@@ -279,6 +286,15 @@ sql.raw = (text) => ({
279
286
  params: [],
280
287
  [SQL_EXPR_BRAND]: true,
281
288
  });
289
+ function isIndexOrPkList(opts) {
290
+ return Array.isArray(opts);
291
+ }
292
+ function isPrimaryKeyDescriptor(value) {
293
+ return "__pk" in value && value.__pk === true;
294
+ }
295
+ function isIndexBuilderWithCols(value) {
296
+ return "__index" in value && value.__index === true;
297
+ }
282
298
  export function table(tableName, cols, optsFn) {
283
299
  // Finalise columns + build the column-handle map.
284
300
  const handles = {};
@@ -316,25 +332,24 @@ export function table(tableName, cols, optsFn) {
316
332
  if (optsFn) {
317
333
  const tHandle = handles;
318
334
  const opts = optsFn(tHandle);
319
- const entries = Array.isArray(opts)
335
+ const entries = isIndexOrPkList(opts)
320
336
  ? opts.map((v, i) => [String(i), v])
321
337
  : Object.entries(opts);
322
338
  for (const [key, value] of entries) {
323
339
  if (!value)
324
340
  continue;
325
- if ("__pk" in value && value.__pk === true) {
341
+ if (isPrimaryKeyDescriptor(value)) {
326
342
  compositePrimaryKey = {
327
343
  name: value.name ?? `${tableName}_pk`,
328
344
  columns: value.columns,
329
345
  };
330
346
  }
331
- else if ("__index" in value && value.__index === true) {
332
- const idx = value;
347
+ else if (isIndexBuilderWithCols(value)) {
333
348
  indexes.push({
334
- name: idx.name,
335
- columns: idx.columns,
336
- ...(idx.unique && { unique: true }),
337
- ...(idx.whereSql !== undefined && { whereSql: idx.whereSql }),
349
+ name: value.name,
350
+ columns: value.columns,
351
+ ...(value.unique && { unique: true }),
352
+ ...(value.whereSql !== undefined && { whereSql: value.whereSql }),
338
353
  });
339
354
  }
340
355
  else {
@@ -367,8 +382,8 @@ export function extractTableName(table, context = "extractTableName") {
367
382
  if (typeof table !== "object" || table === null) {
368
383
  throw new Error(`${context}: table is not an object`);
369
384
  }
370
- const name = table[KUMIKO_NAME_SYMBOL];
371
- if (typeof name !== "string") {
385
+ const name = schemaTableName(table);
386
+ if (name === undefined) {
372
387
  throw new Error(`${context}: table missing kumiko:schema:Name symbol`);
373
388
  }
374
389
  return name;
@@ -18,7 +18,7 @@
18
18
  // Custom handlers must pass the viewer explicitly so the parentRef gate is
19
19
  // never skipped silently.
20
20
  import { requestContext } from "../api/request-context.js";
21
- import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
21
+ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
22
22
  import { selectMany } from "../db/query.js";
23
23
  import { filterReadFields } from "../engine/field-access.js";
24
24
  import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
@@ -162,7 +162,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
162
162
  const rawRefRows = scope.kind === "restricted"
163
163
  ? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
164
164
  : lookedUpRows;
165
- const piiFields = collectPiiEncryptedFields(refEntity);
165
+ const piiFields = collectPiiSubjectFields(refEntity);
166
166
  const encryptedFields = collectEncryptedFieldNames(refEntity);
167
167
  const kms = configuredPiiSubjectKms();
168
168
  const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
@@ -17,7 +17,7 @@ export function entityTableFromRegistry(registry, entityName, entity) {
17
17
  continue;
18
18
  if (projection.source !== entityName)
19
19
  continue;
20
- // @cast-boundary registry-projection — ProjectionDefinition.table is the
20
+ // @cast-boundary projection-table — ProjectionDefinition.table is the
21
21
  // untyped table object every projection registrar accepts.
22
22
  return projection.table;
23
23
  }
@@ -16,7 +16,7 @@
16
16
  // hand-built path; app author owns tenant scoping / versioning. Prefer a
17
17
  // `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
18
18
  import { collectPiiEncryptedFields } from "../crypto/index.js";
19
- import { SQL_EXPR_BRAND } from "./dialect.js";
19
+ import { isSqlExpression } from "./dialect.js";
20
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).
@@ -226,9 +226,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
226
226
  // Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
227
227
  // feature.entities), yet encryptForDirectWrite still keys recordOwned fields as
228
228
  // `record:<entity>:<id>`; forgetSubject requires a UUID, so the value could never be shredded.
229
- const recordOwnedField = Object.entries(entity.fields).find(
230
- // @cast-boundary schema-walk — PII flags live on the resolved field annotation
231
- ([, field]) => field.recordOwned === true);
229
+ const recordOwnedField = Object.entries(entity.fields).find(([, field]) => "recordOwned" in field && field.recordOwned === true);
232
230
  if (recordOwnedField) {
233
231
  throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
234
232
  }
@@ -302,7 +300,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
302
300
  // Explicitly declared indexes (EntityIndexDef). `def.where` is a
303
301
  // SqlExpression (`sql\`…\`` from @cosmicdrift/kumiko-framework/db) —
304
302
  // renderable via `.text`. Unknown where-shapes stay needsManualWhere.
305
- for (const def of (entity.indexes ?? [])) {
303
+ for (const def of entity.indexes ?? []) {
306
304
  const cols = def.columns.map((fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName));
307
305
  const suffix = def.unique === true ? "unique" : "idx";
308
306
  const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
@@ -358,13 +356,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
358
356
  /** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
359
357
  export const buildEntityTableMeta = deriveEntityTableMeta;
360
358
  function sqlExpressionText(where) {
361
- if (typeof where === "object" &&
362
- where !== null &&
363
- SQL_EXPR_BRAND in where &&
364
- typeof where.text === "string") {
365
- return where.text;
366
- }
367
- return undefined;
359
+ return isSqlExpression(where) ? where.text : undefined;
368
360
  }
369
361
  // Validates that a backing Drizzle table (declared via `r.entity(name, def,
370
362
  // { table })`) is a SUPERSET of the field-derived meta: every column the
@@ -423,7 +415,7 @@ export function defineUnmanagedTable(input) {
423
415
  }),
424
416
  source: "unmanaged",
425
417
  ...(input.tenancy === "global" && { tenancy: "global" }),
426
- // @cast-boundary type-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
418
+ // @cast-boundary phantom-brand — TenancyBrand<T> is a phantom marker with no runtime representation.
427
419
  };
428
420
  }
429
421
  function assertUnmanagedTableName(tableName, via) {
@@ -33,15 +33,14 @@ export type ExecutorContext = {
33
33
  readonly includeDeleted?: boolean;
34
34
  }) => Promise<Record<string, unknown> | null>;
35
35
  readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
36
- readonly loadWithOwnership: (db: TenantDb, idWhere: WhereObject, ownership: {
37
- kind: "pass";
38
- } | {
39
- kind: "empty";
40
- } | {
36
+ readonly probeOwnershipWithStreamVersion: (db: TenantDb, idWhere: WhereObject, ownership: {
41
37
  kind: "sql";
42
38
  sqlText: string;
43
39
  params: readonly unknown[];
44
- }) => Promise<Record<string, unknown>[]>;
40
+ }, streamTenantId: TenantId, aggregateId: string) => Promise<{
41
+ readonly owned: boolean;
42
+ readonly streamVersion: number;
43
+ }>;
45
44
  readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
46
45
  kind: "pass";
47
46
  } | {
@@ -1,6 +1,5 @@
1
- import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
1
  import { requestContext } from "../api/request-context.js";
3
- import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
2
+ import { collectPiiEncryptedFields, collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
4
3
  import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
5
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
6
5
  import { shiftParams } from "../engine/ownership.js";
@@ -12,6 +11,7 @@ import { rehydrateCompoundTypes } from "./compound-types.js";
12
11
  import { collectEncryptedFieldNames, decryptEntityFieldValues, encryptEntityFieldValues, logUndecryptableField, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
13
12
  import { constraintOf, isUniqueViolation } from "./pg-error.js";
14
13
  import { toSnakeCase } from "./table-builder.js";
14
+ import { columnHandleName, schemaTableName } from "./table-introspection.js";
15
15
  import { tenantDbRunner } from "./tenant-db-runner.js";
16
16
  // Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
17
17
  // Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
@@ -173,6 +173,11 @@ export function buildExecutorContext(table, entity, options) {
173
173
  const hasEncryptedFields = encryptedFields.size > 0;
174
174
  const piiSubjectFields = collectPiiEncryptedFields(entity);
175
175
  const hasPiiFields = piiSubjectFields.length > 0;
176
+ // Decrypt keeps file-reference fields: rows and event payloads written by
177
+ // the old backfill may hold ciphertext there, and decrypt passes plaintext
178
+ // through untouched.
179
+ const piiDecryptFields = collectPiiSubjectFields(entity);
180
+ const hasPiiDecryptFields = piiDecryptFields.length > 0;
176
181
  function fieldCipher() {
177
182
  if (options.encryption)
178
183
  return options.encryption;
@@ -230,8 +235,8 @@ export function buildExecutorContext(table, entity, options) {
230
235
  async function decryptForRead(row, opts) {
231
236
  let out = row;
232
237
  const kms = piiKms();
233
- if (hasPiiFields && kms) {
234
- out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
238
+ if (hasPiiDecryptFields && kms) {
239
+ out = await decryptPiiFieldValues(out, piiDecryptFields, kms, kmsContextFor());
235
240
  }
236
241
  if (hasEncryptedFields) {
237
242
  out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
@@ -301,29 +306,11 @@ export function buildExecutorContext(table, entity, options) {
301
306
  throw new ArchivedStreamError(tenantId, String(id));
302
307
  }
303
308
  }
304
- // SELECT a row by id with the ownership clause applied at the DB layer.
305
- // Detail() uses this both on cold path and as a cache-revalidation probe.
306
- async function loadWithOwnership(db, idWhere, ownership) {
307
- if (ownership.kind === "empty")
308
- return [];
309
- if (ownership.kind === "pass") {
310
- const row = await db.fetchOne(table, idWhere);
311
- return row ? [row] : [];
312
- }
313
- // ownership has raw SQL — splice it into a raw query alongside the
314
- // idFilter + tenant-filter that TenantDb would have added.
315
- const tableName = String(table[KUMIKO_NAME_SYMBOL]);
316
- const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
317
- const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
318
- return [
319
- ...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
320
- ];
321
- }
322
309
  // Tenant filter + idFilter + ownership predicate as raw SQL, mirroring what
323
310
  // TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
324
311
  // `isDeleted`, so no null/IS NULL branch is needed.
325
312
  function buildRawIdWhere(db, idWhere, ownership) {
326
- const colSql = (field) => `"${table[field]?.name ?? toSnakeCase(field)}"`;
313
+ const colSql = (field) => `"${columnHandleName(table, field) ?? toSnakeCase(field)}"`;
327
314
  const whereParts = [];
328
315
  const params = [];
329
316
  if (table["tenantId"] !== undefined && db.mode === "tenant") {
@@ -351,13 +338,28 @@ export function buildExecutorContext(table, entity, options) {
351
338
  return (`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${aggregateIdParam} ` +
352
339
  `AND "tenant_id" = $${tenantIdParam}) AS "__streamVersion"`);
353
340
  }
341
+ // Cache-hit revalidation: ownership predicate and live stream version in ONE
342
+ // statement, so a cache hit costs the same single roundtrip as the cold path.
343
+ async function probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantId, aggregateId) {
344
+ const tableName = String(schemaTableName(table));
345
+ const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
346
+ params.push(aggregateId, streamTenantId);
347
+ const sqlText = `SELECT 1 AS "__owned", ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
348
+ `FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
349
+ const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
350
+ const first = rows[0];
351
+ if (!first)
352
+ return { owned: false, streamVersion: 0 };
353
+ const version = first["__streamVersion"];
354
+ return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
355
+ }
354
356
  // Row and MAX(version) in ONE statement: two reads can pair a post-write
355
357
  // version with a pre-write row, and update({version: detail.version}) would
356
358
  // then pass the optimistic lock and overwrite the newer write (lost update).
357
359
  async function loadDetailSnapshot(db, idWhere, ownership, streamTenantId, aggregateId) {
358
360
  if (ownership.kind === "empty")
359
361
  return { row: null, streamVersion: 0 };
360
- const tableName = String(table[KUMIKO_NAME_SYMBOL]);
362
+ const tableName = String(schemaTableName(table));
361
363
  const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
362
364
  params.push(aggregateId, streamTenantId);
363
365
  const sqlText = `SELECT *, ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
@@ -393,8 +395,8 @@ export function buildExecutorContext(table, entity, options) {
393
395
  }
394
396
  }
395
397
  const quote = (name) => `"${name.replace(/"/g, '""')}"`;
396
- const columnOf = (field) => quote(table[field]?.name ?? toSnakeCase(field));
397
- const tableName = String(table[KUMIKO_NAME_SYMBOL]);
398
+ const columnOf = (field) => quote(columnHandleName(table, field) ?? toSnakeCase(field));
399
+ const tableName = String(schemaTableName(table));
398
400
  const selectCols = expectKeys.map((key) => `${columnOf(key)} AS ${quote(key)}`);
399
401
  const whereParts = [`${columnOf("id")} = $1`];
400
402
  const params = [id];
@@ -431,7 +433,7 @@ export function buildExecutorContext(table, entity, options) {
431
433
  idFilter,
432
434
  loadById,
433
435
  assertStreamWritable,
434
- loadWithOwnership,
436
+ probeOwnershipWithStreamVersion,
435
437
  loadDetailSnapshot,
436
438
  loadExpectSnapshot,
437
439
  encryptForStorage,
@@ -1,4 +1,4 @@
1
- import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
2
  import { escapeLikePattern } from "../crypto/ciphertext-pattern.js";
3
3
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/index.js";
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
@@ -6,7 +6,7 @@ import { coerceRow, extractTableInfo } from "../db/query.js";
6
6
  import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
7
7
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
8
8
  import { UnprocessableError } from "../errors/index.js";
9
- import { getStreamVersion, getStreamVersions } from "../event-store/index.js";
9
+ 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";
@@ -14,6 +14,7 @@ import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-en
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";
17
+ import { schemaTableName } from "./table-introspection.js";
17
18
  import { tenantDbRunner } from "./tenant-db-runner.js";
18
19
  // The two read verbs (list/detail) of the event-store-executor. Split out
19
20
  // of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
@@ -57,7 +58,7 @@ function toCursorSortText(value) {
57
58
  if (value instanceof Date)
58
59
  return value.toISOString();
59
60
  if (typeof value === "object") {
60
- const tag = value[Symbol.toStringTag];
61
+ const tag = Reflect.get(value, Symbol.toStringTag);
61
62
  if (typeof tag === "string" && tag.startsWith("Temporal."))
62
63
  return String(value);
63
64
  return JSON.stringify(value);
@@ -154,9 +155,8 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
154
155
  });
155
156
  whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
156
157
  }
157
- else if (typeof value === "object") {
158
- const valueObj = value;
159
- if (valueObj["ne"] === null && Object.keys(valueObj).length === 1) {
158
+ else if (isPlainObject(value)) {
159
+ if (value["ne"] === null && Object.keys(value).length === 1) {
160
160
  whereSql.push(`${colSql(field)} IS NOT NULL`);
161
161
  continue;
162
162
  }
@@ -362,7 +362,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
362
362
  return undefined;
363
363
  }
364
364
  const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
365
- const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
365
+ const targetTableName = String(schemaTableName(targetTable));
366
366
  if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
367
367
  return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
368
368
  }
@@ -387,6 +387,7 @@ function referenceTargetTable(name, entity) {
387
387
  // Projected alongside the row when a reference sort is active, so the keyset
388
388
  // cursor carries the label that was ordered by instead of the FK's UUID.
389
389
  const SORT_LABEL_ALIAS = "__kumiko_sort_label";
390
+ const STREAM_VERSION_ALIAS = "__streamVersion";
390
391
  // fw#2741 — resolve a `sortable: true` reference field's label inline for
391
392
  // ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
392
393
  // row per entity row, the read model stays untouched, and the lookup rides the
@@ -419,7 +420,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
419
420
  const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
420
421
  if (ownership.kind === "empty")
421
422
  return undefined;
422
- const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
423
+ const targetTableName = String(schemaTableName(targetTable));
423
424
  const labelCol = physicalColumnName(targetTable, descriptor.labelField);
424
425
  const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
425
426
  const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
@@ -448,7 +449,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
448
449
  };
449
450
  }
450
451
  export function createReadVerbs(ctx) {
451
- const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
452
+ const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, probeOwnershipWithStreamVersion, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
452
453
  return {
453
454
  // list + detail are unchanged from crud-executor — projections are the
454
455
  // read-model and serve these queries directly.
@@ -456,7 +457,7 @@ export function createReadVerbs(ctx) {
456
457
  const runner = tenantDbRunner(db);
457
458
  const { limit, offset } = resolveListPagination(payload);
458
459
  const totalCount = payload.totalCount === true;
459
- const tableName = String(table[KUMIKO_NAME_SYMBOL]);
460
+ const tableName = String(schemaTableName(table));
460
461
  // H.2 — entity-level read ownership, AND-ed with the parent-ref
461
462
  // read-gate (join-row entities derive their visibility from a host
462
463
  // row's own read path — see db/parent-ref-clause.ts). Decide before
@@ -644,15 +645,28 @@ export function createReadVerbs(ctx) {
644
645
  const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
645
646
  // Project the resolved label too, so the keyset cursor below carries the
646
647
  // value that was ordered by rather than the FK column's UUID.
647
- const selectList = referenceSortExpr !== undefined && sortSql !== undefined
648
- ? `*, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
649
- : "*";
648
+ const sortLabelProjection = referenceSortExpr !== undefined && sortSql !== undefined
649
+ ? `, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
650
+ : "";
651
+ // Stream version in the same statement as the rows: a separate read could pair a
652
+ // post-write version with a pre-write row, and update({version}) would then pass
653
+ // the optimistic lock against a state the client never saw (see loadDetailSnapshot).
654
+ params.push(streamTenantFor(user));
655
+ const streamVersionProjection = `, (SELECT MAX("version") FROM "kumiko_events" ` +
656
+ `WHERE "aggregate_id" = "${tableName}"."id" AND "tenant_id" = $${params.length}) ` +
657
+ `AS "${STREAM_VERSION_ALIAS}"`;
658
+ const selectList = `*${sortLabelProjection}${streamVersionProjection}`;
650
659
  const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
651
660
  const rawRows = await executeRawQueryRead(runner, listSql, params);
652
661
  const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
662
+ // Aliases are query artifacts, not columns of this entity — strip them before
663
+ // coercion so they never reach the caller or the entity cache.
664
+ const streamVersions = rawRows.map((r) => {
665
+ const v = r[STREAM_VERSION_ALIAS];
666
+ delete r[STREAM_VERSION_ALIAS];
667
+ return typeof v === "number" ? v : 0;
668
+ });
653
669
  if (referenceSortExpr !== undefined) {
654
- // The alias is a sort artifact, not a column of this entity — strip it
655
- // before coercion so it never reaches the caller or the entity cache.
656
670
  for (const row of rawRows)
657
671
  delete row[SORT_LABEL_ALIAS];
658
672
  }
@@ -668,10 +682,7 @@ export function createReadVerbs(ctx) {
668
682
  // Stream version is authoritative (see detail()): ctx.appendEvent bumps the
669
683
  // stream without touching row.version, so the raw row version would hand
670
684
  // clients a stale optimistic-lock base. Rows without events keep theirs.
671
- const streamVersions = decryptedRows.length > 0
672
- ? await getStreamVersions(runner, decryptedRows.map((r) => String(r["id"])), streamTenantFor(user))
673
- : new Map();
674
- const rows = decryptedRows.map((r) => withStreamVersion(r, streamVersions.get(String(r["id"])) ?? 0));
685
+ const rows = decryptedRows.map((r, i) => withStreamVersion(r, streamVersions[i] ?? 0));
675
686
  // Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
676
687
  // detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
677
688
  // a cached version that differs from the stream.
@@ -709,7 +720,7 @@ export function createReadVerbs(ctx) {
709
720
  };
710
721
  },
711
722
  async detail(payload, user, db, options) {
712
- const tableName = String(table[KUMIKO_NAME_SYMBOL]);
723
+ const tableName = String(schemaTableName(table));
713
724
  // H.2 — ownership check, AND-ed with the parent-ref read-gate (see
714
725
  // db/parent-ref-clause.ts). `empty` → the user can never see this row
715
726
  // regardless of its id. Return null (same shape as "not found", so a
@@ -730,16 +741,20 @@ export function createReadVerbs(ctx) {
730
741
  if (entityCache && entityName) {
731
742
  const cached = await entityCache.get(user.tenantId, entityName, payload.id);
732
743
  if (cached) {
744
+ let streamVersion;
733
745
  if (ownership.kind === "sql") {
734
746
  // Re-check ownership predicate (incl. the parent-ref gate) against
735
747
  // the live row — the cache is keyed only by tenant + id, not by
736
- // role, so a cache hit can't bypass either gate. Deliberate price:
737
- // one extra roundtrip per cache hit once either gate produces SQL.
738
- const checkRows = await loadWithOwnership(db, idWhere, ownership);
739
- if (checkRows.length === 0)
748
+ // role, so a cache hit can't bypass either gate. The probe carries
749
+ // the stream version in the same statement.
750
+ const probe = await probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantFor(user), String(payload.id));
751
+ if (!probe.owned)
740
752
  return null;
753
+ streamVersion = probe.streamVersion;
754
+ }
755
+ else {
756
+ streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
741
757
  }
742
- const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
743
758
  if (!(streamVersion > 0 && cached["version"] !== streamVersion)) {
744
759
  // Cached rows are stored re-encrypted (see the `set` below) so an
745
760
  // `encrypted` field's plaintext never sits in a second at-rest
@@ -3,7 +3,6 @@ import { instructionFieldNames } from "../engine/instruction-fields.js";
3
3
  import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
4
4
  import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
5
5
  import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
6
- import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
7
6
  import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
8
7
  import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
9
8
  import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
@@ -12,10 +11,11 @@ import { generateId } from "../utils/index.js";
12
11
  import { applyEntityEvent } from "./apply-entity-event.js";
13
12
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
14
13
  import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
14
+ import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
15
15
  import { projectionRegistryOf } from "./projection-registry-binding.js";
16
16
  import { runInSavepointIfSupported } from "./query.js";
17
17
  import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
18
- import { tenantDbRunner } from "./tenant-db-runner.js";
18
+ import { tenantDbWriteRunner } from "./tenant-db-runner.js";
19
19
  // Custom projections for a write that did not go through the dispatcher (hook or
20
20
  // job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
21
21
  // projection fails the write and the caller's transaction rolls it back.
@@ -29,12 +29,6 @@ async function projectEntityEvent(db, runner, event) {
29
29
  return;
30
30
  await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
31
31
  }
32
- // Art. 17 erasure runs as the framework operator, not as a row owner; a
33
- // per-role ownership map can never cover it, and a silent deny means the
34
- // erasure never happened.
35
- function isFrameworkSystemUser(user) {
36
- return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
37
- }
38
32
  // The five write verbs (create/update/delete/forget/restore) of the event-
39
33
  // store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
40
34
  // behavior-preserving relocation, not a redesign: every closure below is
@@ -126,9 +120,7 @@ export function createWriteVerbs(ctx) {
126
120
  // statutory deletion must reach locked rows, and forget (Art. 17) never checks the lock
127
121
  // at all. The options are server-code only; no request field is ever copied into them.
128
122
  function recordLockedFailure(row, id, user, options) {
129
- if (options?.unsafeSkipLockGuard === true ||
130
- (options?.writePurpose !== undefined && isFrameworkSystemUser(user)) ||
131
- !isRecordLocked(entity.lockedWhen, row)) {
123
+ if (isLockGuardBypassed(user, options) || !isRecordLocked(entity.lockedWhen, row)) {
132
124
  return undefined;
133
125
  }
134
126
  return writeFailure(new RecordLockedError({ entityName, entityId: id }));
@@ -180,7 +172,7 @@ export function createWriteVerbs(ctx) {
180
172
  const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
181
173
  if (streamTenantMismatch)
182
174
  return streamTenantMismatch;
183
- const runner = tenantDbRunner(db);
175
+ const runner = tenantDbWriteRunner(db, "ctx.db executor create");
184
176
  // Respect an explicit id in the payload (seed pattern, SCIM import). Without
185
177
  // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
186
178
  // event payload so defaults + downstream consumers don't see a redundant id field.
@@ -349,7 +341,7 @@ export function createWriteVerbs(ctx) {
349
341
  };
350
342
  },
351
343
  async update(payload, user, db, updateOptions) {
352
- const runner = tenantDbRunner(db);
344
+ const runner = tenantDbWriteRunner(db, "ctx.db executor update");
353
345
  if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
354
346
  throw new AccessDeniedError({
355
347
  message: `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
@@ -582,7 +574,7 @@ export function createWriteVerbs(ctx) {
582
574
  if (isIrreversibleEntityVerb("delete", entity)) {
583
575
  assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
584
576
  }
585
- const runner = tenantDbRunner(db);
577
+ const runner = tenantDbWriteRunner(db, "ctx.db executor delete");
586
578
  const existing = await loadById(payload.id, db);
587
579
  if (!existing)
588
580
  return writeFailure(new NotFoundError(entityName, payload.id));
@@ -664,6 +656,7 @@ export function createWriteVerbs(ctx) {
664
656
  data: existing,
665
657
  entityName,
666
658
  event: echoEvent,
659
+ ...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
667
660
  },
668
661
  };
669
662
  },
@@ -673,7 +666,7 @@ export function createWriteVerbs(ctx) {
673
666
  // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
674
667
  async forget(payload, user, db, options) {
675
668
  assertIrreversibleOperationAllowed(`${entityName}.forget`);
676
- const runner = tenantDbRunner(db);
669
+ const runner = tenantDbWriteRunner(db, "ctx.db executor forget");
677
670
  const raw = await db.fetchOne(table, { id: payload.id });
678
671
  if (!raw)
679
672
  return writeFailure(new NotFoundError(entityName, payload.id));
@@ -744,6 +737,7 @@ export function createWriteVerbs(ctx) {
744
737
  data: existing,
745
738
  entityName,
746
739
  event: echoEvent,
740
+ lockGuardBypassed: true,
747
741
  },
748
742
  };
749
743
  },
@@ -753,7 +747,7 @@ export function createWriteVerbs(ctx) {
753
747
  i18nKey: "errors.softDeleteNotEnabled",
754
748
  }));
755
749
  }
756
- const runner = tenantDbRunner(db);
750
+ const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
757
751
  const streamTenantId = options?.streamTenantId;
758
752
  const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
759
753
  if ("failure" in target)
@@ -780,8 +774,13 @@ export function createWriteVerbs(ctx) {
780
774
  // Restore carries the soft-deleted snapshot as `previous` — mirror of
781
775
  // delete for symmetry. Projections that decremented on delete use
782
776
  // `previous` to re-increment on restore without re-querying the entity
783
- // table. `data` is the raw stored row — pii/encrypted fields are
784
- // already ciphertext, no re-encrypt needed.
777
+ // table. Rows predating the subject-KMS rollout may still hold plaintext
778
+ // pii, and envelope encryption is not idempotent: decrypt, then
779
+ // re-encrypt as delete does.
780
+ const previousPlain = await decryptForRead(data);
781
+ const previousForStorage = await encryptForStorage(previousPlain, user, {
782
+ keyTenantId: streamTenantId,
783
+ });
785
784
  let event;
786
785
  try {
787
786
  event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
@@ -791,7 +790,7 @@ export function createWriteVerbs(ctx) {
791
790
  expectedVersion: currentVersion,
792
791
  type: entityEventName(entityName, "restored"),
793
792
  eventVersion,
794
- payload: { previous: data },
793
+ payload: { previous: previousForStorage },
795
794
  metadata: buildEventMetadata(user),
796
795
  }));
797
796
  }
@@ -835,7 +834,6 @@ export function createWriteVerbs(ctx) {
835
834
  // same as every other executor method — `data`/`restored` are raw rows
836
835
  // (db.fetchOne / applyEntityEvent), never decrypted before this point.
837
836
  const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
838
- const previousPlain = await decryptForRead(data);
839
837
  const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
840
838
  await projectEntityEvent(db, runner, echoEvent);
841
839
  return {
@@ -35,4 +35,4 @@ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFramework
35
35
  export { tableExists } from "./schema-inspection.js";
36
36
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
37
37
  export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
38
- export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
38
+ export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";