@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -15,6 +15,7 @@ import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-p
15
15
  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
+ import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
18
19
  import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
19
20
  import { columnNamesOf, tableExists } from "./schema-inspection.js";
20
21
  export async function nullBlindIndexesForSubject(db, features, subjectKey) {
@@ -55,7 +56,7 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
55
56
  async function existingTableNames(db, tableNames) {
56
57
  if (tableNames.length === 0)
57
58
  return new Set();
58
- const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [tableNames]);
59
+ const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [toPostgresArrayLiteral(tableNames)]);
59
60
  return new Set(rows.map((r) => r.name));
60
61
  }
61
62
  // Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
@@ -1,12 +1,11 @@
1
- // Bun.SQL Provider — experimentell.
2
- // Default: postgres-js. Dieser Provider nur aktiv wenn DB_PROVIDER=bun.
3
- // Bekannter Bug: Extended-Query-Protocol Caching verursacht
4
- // PostgresError "bind message has 11 result formats but query has 1 columns"
5
- // bei sequentiellen Queries mit unterschiedlicher Spaltenzahl innerhalb
6
- // derselben Connection.
1
+ // Bun.SQL provider, opt-in via DB_PROVIDER=bun; postgres-js stays the default.
2
+ // Known bug (Bun <= 1.4.3, oven-sh/bun#29484): a cached plan is not re-prepared
3
+ // after DDL, so e.g. `SELECT *` after ADD COLUMN fails on that connection with
4
+ // 0A000 "cached plan must not change result type". prepare:false is no fix,
5
+ // it changes how jsonb params are bound.
7
6
  //
8
- // Bun.SQL hat kein LISTEN — postgres-js-Peer für event-dispatcher.
9
- import postgres from "postgres";
7
+ // LISTEN runs on Bun.SQL's own dedicated connection, which is exempt from the
8
+ // pool's idleTimeout and maxLifetime and re-LISTENs after a drop.
10
9
  import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS, } from "./api.js";
11
10
  export function createBunConnection(url, options = {}) {
12
11
  const bunOpts = {};
@@ -17,21 +16,13 @@ export function createBunConnection(url, options = {}) {
17
16
  if (options.connectTimeoutSeconds !== undefined)
18
17
  bunOpts.connectionTimeout = options.connectTimeoutSeconds;
19
18
  const db = new Bun.SQL(url, bunOpts);
20
- // LISTEN peer — 1 connection reicht für NOTIFY-Wakeups
21
- const pgOpts = { max: 1 };
22
- if (options.idleTimeoutSeconds !== undefined)
23
- pgOpts.idle_timeout = options.idleTimeoutSeconds;
24
- if (options.connectTimeoutSeconds !== undefined)
25
- pgOpts.connect_timeout = options.connectTimeoutSeconds;
26
- const listenClient = postgres(url, pgOpts);
27
19
  const closeTimeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
28
20
  return {
29
21
  db,
30
- client: listenClient,
31
- listenClient,
22
+ client: db,
23
+ listenClient: db,
32
24
  close: async () => {
33
25
  await db.end({ timeout: closeTimeout });
34
- await listenClient.end({ timeout: closeTimeout });
35
26
  },
36
27
  };
37
28
  }
@@ -15,6 +15,15 @@ function canonicalColumnsKey(meta) {
15
15
  .map((c) => `${c.name}|${c.pgType}|${c.notNull}|${c.defaultSql ?? ""}|${c.primaryKey ?? false}|${c.identity ?? false}|${c.bigintJsMode ?? ""}`)
16
16
  .join("\n");
17
17
  }
18
+ function registerPass1Meta(metas, byName, meta, origin) {
19
+ const existing = byName.get(meta.tableName);
20
+ if (existing) {
21
+ throw new Error(`collectTableMetas: table "${meta.tableName}" is registered by ${origin} and ${existing.origin}. ` +
22
+ "Give one of them a different table name.");
23
+ }
24
+ metas.push(meta);
25
+ byName.set(meta.tableName, { meta, origin });
26
+ }
18
27
  export function collectTableMetas(features) {
19
28
  const metas = [];
20
29
  const byName = new Map();
@@ -37,15 +46,10 @@ export function collectTableMetas(features) {
37
46
  assertBackingTableSuperset(name, fieldMeta, tableMeta);
38
47
  meta = tableMeta;
39
48
  }
40
- metas.push(meta);
41
- byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
49
+ registerPass1Meta(metas, byName, meta, `entity "${name}" (${feature.name})`);
42
50
  }
43
51
  for (const entry of Object.values(feature.storeTables)) {
44
- metas.push(entry.meta);
45
- byName.set(entry.meta.tableName, {
46
- meta: entry.meta,
47
- origin: `storeTable "${entry.name}" (${feature.name})`,
48
- });
52
+ registerPass1Meta(metas, byName, entry.meta, `storeTable "${entry.name}" (${feature.name})`);
49
53
  }
50
54
  }
51
55
  // Pass 2: table-tragende Registrierungen. Zwei Pässe, damit eine Entity-
@@ -54,7 +54,7 @@ export declare function instant(name: string, opts?: {
54
54
  }): ColumnBuilder<Temporal.Instant>;
55
55
  export declare function plainDate(name: string): ColumnBuilder<Temporal.PlainDate>;
56
56
  export declare const decimalColumn: (name: string, precision: number, scale: number) => ColumnBuilder<number>;
57
- export declare const moneyAmount: (name: string) => ColumnBuilder<number>;
57
+ export declare const moneyAmount: (name: string) => ColumnBuilder<bigint>;
58
58
  export type IndexBuilder = {
59
59
  readonly __index: true;
60
60
  on(...cols: ColumnHandle[]): IndexBuilderWithCols;
@@ -58,6 +58,8 @@ export function pgTypeToSqlType(pgType) {
58
58
  function _toSnakeCase(name) {
59
59
  return name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`).replace(/^_/, "");
60
60
  }
61
+ // TValue is never inferred from the factory's declared return type, so a factory that forgets its
62
+ // type argument fails to compile instead of silently typing the column from its own annotation.
61
63
  function buildColumn(sqlName, pgType, opts) {
62
64
  let notNull = false;
63
65
  let primaryKey = false;
@@ -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";
@@ -26,6 +26,7 @@ import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
26
26
  import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
27
27
  import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
28
28
  import { buildParentRefClause } from "./parent-ref-clause.js";
29
+ import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
29
30
  import { executeRawQueryRead } from "./queries/raw-sql.js";
30
31
  import { buildEntityTable, physicalColumnName } from "./table-builder.js";
31
32
  import { tenantDbRunner } from "./tenant-db-runner.js";
@@ -77,7 +78,7 @@ async function resolveRefAccessScope(refEntity, refTable, refTableName, ids, db,
77
78
  // No read path exists for non-uuid ids, so nothing can be access-checked.
78
79
  if (refEntity.idType !== undefined && refEntity.idType !== "uuid")
79
80
  return { kind: "none" };
80
- const params = [ids];
81
+ const params = [toPostgresArrayLiteral(ids)];
81
82
  let tenantClause = "";
82
83
  if (refTable["tenantId"] !== undefined && db.mode === "tenant") {
83
84
  params.push(db.tenantId, SYSTEM_TENANT_ID);
@@ -162,7 +163,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
162
163
  const rawRefRows = scope.kind === "restricted"
163
164
  ? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
164
165
  : lookedUpRows;
165
- const piiFields = collectPiiEncryptedFields(refEntity);
166
+ const piiFields = collectPiiSubjectFields(refEntity);
166
167
  const encryptedFields = collectEncryptedFieldNames(refEntity);
167
168
  const kms = configuredPiiSubjectKms();
168
169
  const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
@@ -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
  } | {
@@ -63,6 +62,7 @@ export type ExecutorContext = {
63
62
  subjectSource?: Record<string, unknown>;
64
63
  keyTenantId?: TenantId | undefined;
65
64
  ownerFallback?: Record<string, unknown>;
65
+ onErasedSubject?: "sentinel";
66
66
  }) => Promise<Record<string, unknown>>;
67
67
  readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
68
68
  readonly ownerTransitionRekeys: (previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>) => Record<string, unknown>;
@@ -1,5 +1,5 @@
1
1
  import { requestContext } from "../api/request-context.js";
2
- import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
2
+ import { collectPiiEncryptedFields, collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
3
3
  import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
5
  import { shiftParams } from "../engine/ownership.js";
@@ -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;
@@ -210,6 +215,7 @@ export function buildExecutorContext(table, entity, options) {
210
215
  ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
211
216
  ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
212
217
  ...(opts?.ownerFallback !== undefined && { ownerFallback: opts.ownerFallback }),
218
+ ...(opts?.onErasedSubject !== undefined && { onErasedSubject: opts.onErasedSubject }),
213
219
  });
214
220
  }
215
221
  catch (e) {
@@ -230,8 +236,8 @@ export function buildExecutorContext(table, entity, options) {
230
236
  async function decryptForRead(row, opts) {
231
237
  let out = row;
232
238
  const kms = piiKms();
233
- if (hasPiiFields && kms) {
234
- out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
239
+ if (hasPiiDecryptFields && kms) {
240
+ out = await decryptPiiFieldValues(out, piiDecryptFields, kms, kmsContextFor());
235
241
  }
236
242
  if (hasEncryptedFields) {
237
243
  out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
@@ -301,24 +307,6 @@ export function buildExecutorContext(table, entity, options) {
301
307
  throw new ArchivedStreamError(tenantId, String(id));
302
308
  }
303
309
  }
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(schemaTableName(table));
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
310
  // Tenant filter + idFilter + ownership predicate as raw SQL, mirroring what
323
311
  // TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
324
312
  // `isDeleted`, so no null/IS NULL branch is needed.
@@ -351,6 +339,21 @@ export function buildExecutorContext(table, entity, options) {
351
339
  return (`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${aggregateIdParam} ` +
352
340
  `AND "tenant_id" = $${tenantIdParam}) AS "__streamVersion"`);
353
341
  }
342
+ // Cache-hit revalidation: ownership predicate and live stream version in ONE
343
+ // statement, so a cache hit costs the same single roundtrip as the cold path.
344
+ async function probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantId, aggregateId) {
345
+ const tableName = String(schemaTableName(table));
346
+ const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
347
+ params.push(aggregateId, streamTenantId);
348
+ const sqlText = `SELECT 1 AS "__owned", ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
349
+ `FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
350
+ const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
351
+ const first = rows[0];
352
+ if (!first)
353
+ return { owned: false, streamVersion: 0 };
354
+ const version = first["__streamVersion"];
355
+ return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
356
+ }
354
357
  // Row and MAX(version) in ONE statement: two reads can pair a post-write
355
358
  // version with a pre-write row, and update({version: detail.version}) would
356
359
  // then pass the optimistic lock and overwrite the newer write (lost update).
@@ -431,7 +434,7 @@ export function buildExecutorContext(table, entity, options) {
431
434
  idFilter,
432
435
  loadById,
433
436
  assertStreamWritable,
434
- loadWithOwnership,
437
+ probeOwnershipWithStreamVersion,
435
438
  loadDetailSnapshot,
436
439
  loadExpectSnapshot,
437
440
  encryptForStorage,
@@ -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";
@@ -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
@@ -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.
@@ -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.
@@ -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
@@ -1,3 +1,3 @@
1
- import type { EventStoreExecutor } from "./event-store-executor.js";
1
+ import { type EventStoreExecutor } from "./event-store-executor.js";
2
2
  import { type ExecutorContext } from "./event-store-executor-context.js";
3
3
  export declare function createWriteVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore">;