@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
package/dist/db/index.js CHANGED
@@ -27,4 +27,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
27
27
  export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
28
28
  export { tableExists } from "./schema-inspection.js";
29
29
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
30
- export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
30
+ export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
@@ -0,0 +1,4 @@
1
+ import type { SessionUser } from "../engine/types/index.js";
2
+ import type { LockGuardOption } from "./event-store-executor.js";
3
+ export declare function isFrameworkSystemUser(user: SessionUser): boolean;
4
+ export declare function isLockGuardBypassed(user: SessionUser, options: LockGuardOption | undefined): boolean;
@@ -0,0 +1,14 @@
1
+ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
2
+ // Art. 17 erasure runs as the framework operator, not as a row owner; a
3
+ // per-role ownership map can never cover it, and a silent deny means the
4
+ // erasure never happened.
5
+ export function isFrameworkSystemUser(user) {
6
+ return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
7
+ }
8
+ // Framework system writes that name a writePurpose (retention, erasure) are exempt:
9
+ // statutory deletion must reach locked rows. The options are server-code only; no
10
+ // request field is ever copied into them.
11
+ export function isLockGuardBypassed(user, options) {
12
+ return (options?.unsafeSkipLockGuard === true ||
13
+ (options?.writePurpose !== undefined && isFrameworkSystemUser(user)));
14
+ }
@@ -15,6 +15,7 @@
15
15
  // uncommenten + ggf. Data-Backup-Schritt vorlauf einfügen. Default:
16
16
  // niemals automatisch droppen.
17
17
  import { readFileSync, writeFileSync } from "node:fs";
18
+ import { errorCodeOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
18
19
  import { compareByCodepoint } from "../utils/index.js";
19
20
  import { renderIndex, renderTableDdl } from "./render-ddl.js";
20
21
  import { findRetiredTableDrops } from "./retired-framework-tables.js";
@@ -36,13 +37,14 @@ export function loadSnapshotJson(path) {
36
37
  try {
37
38
  const text = readFileSync(path, "utf8");
38
39
  const parsed = JSON.parse(text);
39
- if (parsed.version !== SNAPSHOT_VERSION) {
40
- throw new Error(`Snapshot at ${path} has version ${String(parsed.version)}, expected ${SNAPSHOT_VERSION}`);
40
+ const version = isPlainObject(parsed) ? parsed["version"] : undefined;
41
+ if (version !== SNAPSHOT_VERSION) {
42
+ throw new Error(`Snapshot at ${path} has version ${String(version)}, expected ${SNAPSHOT_VERSION}`);
41
43
  }
42
44
  return parsed;
43
45
  }
44
46
  catch (err) {
45
- if (err.code === "ENOENT")
47
+ if (errorCodeOf(err) === "ENOENT")
46
48
  return null;
47
49
  throw err;
48
50
  }
@@ -435,7 +437,7 @@ export function assertUniqueTableNames(metas) {
435
437
  const duplicates = [...counts].filter(([, count]) => count > 1);
436
438
  if (duplicates.length > 0) {
437
439
  throw new Error(duplicates
438
- .map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the hand-added meta for "${tableName}".`)
440
+ .map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the duplicate entry for "${tableName}" (a hand-added meta, or two features registering the same table).`)
439
441
  .join("\n"));
440
442
  }
441
443
  }
@@ -16,25 +16,8 @@
16
16
  import { createHash } from "node:crypto";
17
17
  import { existsSync, readdirSync, readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
- // Adapter: extract raw postgres-js client from drizzle DbConnection,
20
- // or use Bun.sql instance directly. Either way `.unsafe()` is the
21
- // runtime call.
22
- function rawClient(db) {
23
- const dbAny = db;
24
- if (typeof dbAny["unsafe"] === "function" && typeof dbAny["begin"] === "function") {
25
- return dbAny;
26
- }
27
- const $client = dbAny["$client"];
28
- if ($client && typeof $client["unsafe"] === "function") {
29
- return $client;
30
- }
31
- const session = dbAny["session"];
32
- const sessionClient = session?.["client"];
33
- if (sessionClient && typeof sessionClient["unsafe"] === "function") {
34
- return sessionClient;
35
- }
36
- throw new Error("migrate-runner: db argument has no .unsafe() (need Bun.SQL or drizzle DbConnection)");
37
- }
19
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
20
+ import { asRawClient, hasBegin } from "../bun-db/query.js";
38
21
  // Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
39
22
  // gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
40
23
  // blockiert bis erster fertig).
@@ -181,24 +164,36 @@ export function readMigrationSqlTexts(dir) {
181
164
  .sort()
182
165
  .map((file) => readFileSync(join(dir, file), "utf8"));
183
166
  }
167
+ // Stricter than asRawClient on purpose: a direct handle must also offer
168
+ // begin(), because runMigrations wraps every file in a transaction. Handles
169
+ // without it fall through to the drizzle wrappers and otherwise fail here,
170
+ // before any SQL runs, with the migrate-runner message.
171
+ function migrationClient(db) {
172
+ for (const candidate of [
173
+ db,
174
+ propertyOf(db, "$client"),
175
+ propertyOf(propertyOf(db, "session"), "client"),
176
+ ]) {
177
+ if (typeof propertyOf(candidate, "unsafe") !== "function")
178
+ continue;
179
+ const client = asRawClient(candidate);
180
+ if (hasBegin(client))
181
+ return client;
182
+ }
183
+ throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need Bun.SQL or drizzle DbConnection)");
184
+ }
184
185
  // Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
185
186
  async function executeRaw(db, sqlText) {
186
- await rawClient(db).unsafe(sqlText);
187
+ await migrationClient(db).unsafe(sqlText);
187
188
  }
188
189
  export async function fetchAppliedMigrations(db) {
189
- const result = await rawClient(db).unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
190
+ const result = await migrationClient(db).unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
190
191
  const rows = Array.isArray(result) ? result : [];
191
192
  const applied = [];
192
193
  for (const row of rows) {
193
- if (typeof row === "object" &&
194
- row !== null &&
195
- typeof row.id === "string" &&
196
- typeof row.checksum === "string") {
197
- applied.push({
198
- id: row.id,
199
- checksum: row.checksum,
200
- });
201
- }
194
+ const { id, checksum } = row;
195
+ if (typeof id === "string" && typeof checksum === "string")
196
+ applied.push({ id, checksum });
202
197
  }
203
198
  return applied;
204
199
  }
@@ -239,13 +234,15 @@ export async function runMigrations(db, migrations) {
239
234
  // Apply file content + INSERT tracking-row in einer TX. Wenn ein
240
235
  // Statement bricht, Rollback inkl. Tracking-Row → kein partial-apply
241
236
  // stuck in den books.
242
- const client = rawClient(db);
237
+ const client = migrationClient(db);
243
238
  await client.begin(async (tx) => {
244
- const txClient = tx;
245
239
  for (const stmt of m.statements) {
246
- await txClient.unsafe(stmt);
240
+ await tx.unsafe(stmt);
247
241
  }
248
- await txClient.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [m.id, m.checksum]);
242
+ await tx.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [
243
+ m.id,
244
+ m.checksum,
245
+ ]);
249
246
  });
250
247
  appliedIds.push(m.id);
251
248
  }
@@ -270,7 +267,7 @@ export async function baselineMigrations(db, migrations) {
270
267
  const applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
271
268
  const marked = [];
272
269
  const alreadyTracked = [];
273
- const client = rawClient(db);
270
+ const client = migrationClient(db);
274
271
  for (const m of migrations) {
275
272
  if (applied.has(m.id)) {
276
273
  alreadyTracked.push(m.id);
@@ -1,14 +1,14 @@
1
- import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
1
  import { buildOwnershipClause } from "../engine/ownership.js";
3
2
  import { SYSTEM_TENANT_ID, UUID_SHAPE_PATTERN } from "../engine/types/identifiers.js";
4
3
  import { buildEntityTable, physicalColumnName } from "./table-builder.js";
4
+ import { schemaTableName } from "./table-introspection.js";
5
5
  const candidateStaticsCache = new WeakMap();
6
6
  function candidateStaticsFor(candidateName, candidate) {
7
7
  const cached = candidateStaticsCache.get(candidate);
8
8
  if (cached)
9
9
  return cached;
10
10
  const table = buildEntityTable(candidateName, candidate);
11
- const tableName = String(table[KUMIKO_NAME_SYMBOL]);
11
+ const tableName = String(schemaTableName(table));
12
12
  const statics = {
13
13
  table,
14
14
  tableName,
@@ -49,6 +49,7 @@
49
49
  // plaintext); the next snapshotting load recreates them. AFTER a run, rebuild the
50
50
  // affected projections — applyEntityEvent materializes ciphertext AND the
51
51
  // blind-index columns, which keeps equality lookups (login by email) alive.
52
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
52
53
  import { asRawClient } from "../../bun-db/index.js";
53
54
  import { quoteIdent } from "../../crypto/ciphertext-pattern.js";
54
55
  import { configuredEventPiiCatalog } from "../../crypto/event-pii.js";
@@ -127,6 +128,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
127
128
  const { entityTargets, eventCatalog } = collectPiiTargets(registry);
128
129
  const aggregateTypes = [...entityTargets.keys()];
129
130
  const catalogTypes = [...eventCatalog.keys()];
131
+ const failures = [];
130
132
  const result = {
131
133
  scannedEvents: 0,
132
134
  updatedEvents: 0,
@@ -136,7 +138,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
136
138
  ownerFromProjection: 0,
137
139
  erasedUnresolvable: 0,
138
140
  deletedSnapshots: 0,
139
- failures: [],
141
+ failures,
140
142
  };
141
143
  const emptyBatch = () => ({
142
144
  ...result,
@@ -148,7 +150,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
148
150
  });
149
151
  if (aggregateTypes.length === 0 && catalogTypes.length === 0)
150
152
  return emptyBatch();
151
- const rows = (await (options.eventIds
153
+ const rows = await (options.eventIds
152
154
  ? raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
153
155
  FROM "kumiko_events"
154
156
  WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" = ANY($3::bigint[])
@@ -157,7 +159,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
157
159
  FROM "kumiko_events"
158
160
  WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
159
161
  ORDER BY "id" ASC
160
- LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize])));
162
+ LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize]));
161
163
  const last = rows[rows.length - 1];
162
164
  if (last === undefined)
163
165
  return emptyBatch();
@@ -203,7 +205,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
203
205
  }
204
206
  // Snapshots may cache the plaintext state of touched aggregates.
205
207
  if (!options.dryRun && touchedAggregates.size > 0) {
206
- const deleted = (await raw.unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [[...touchedAggregates]]));
208
+ const deleted = await raw.unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [[...touchedAggregates]]);
207
209
  result.deletedSnapshots = deleted.length;
208
210
  }
209
211
  return {
@@ -222,7 +224,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
222
224
  // Pre-KMS forgets left no key tombstone — the *.forgotten event on the
223
225
  // stream is the only durable marker. aggregate_id doubles as the user id
224
226
  // for user-subject lookups.
225
- const forgottenRows = (await raw.unsafe(`SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`));
227
+ const forgottenRows = await raw.unsafe(`SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`);
226
228
  scanCache.forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
227
229
  return scanCache.forgottenAggregates;
228
230
  }
@@ -248,7 +250,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
248
250
  // aborts the surrounding transaction (25P02). Entity never mounted/rebuilt
249
251
  // → stage 3 still gets a chance; this batch just contributes no owners.
250
252
  if (await tableExists(db, `public.${tableName}`)) {
251
- const projectionRows = (await raw.unsafe(`SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`, [[...ids]]));
253
+ const projectionRows = await raw.unsafe(`SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`, [[...ids]]);
252
254
  for (const projectionRow of projectionRows) {
253
255
  const id = projectionRow["id"];
254
256
  if (typeof id === "string") {
@@ -273,7 +275,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
273
275
  erasedUnresolvable: 0,
274
276
  };
275
277
  const payload = structuredClone(row.payload);
276
- const tenantId = row.tenant_id; // @cast-boundary db-read — tenant_id column is the branded TenantId
278
+ const tenantId = row.tenant_id;
277
279
  const catalogFields = eventCatalog.get(row.type);
278
280
  if (catalogFields) {
279
281
  for (const [field, spec] of Object.entries(catalogFields)) {
@@ -443,7 +445,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
443
445
  async function loadDestroyedTenants() {
444
446
  if (scanCache.destroyedTenants)
445
447
  return scanCache.destroyedTenants;
446
- const rows = (await raw.unsafe(`SELECT id::text AS id FROM read_tenants WHERE destroyed_at IS NOT NULL`));
448
+ const rows = await raw.unsafe(`SELECT id::text AS id FROM read_tenants WHERE destroyed_at IS NOT NULL`);
447
449
  scanCache.destroyedTenants = new Set(rows.map((r) => r.id));
448
450
  return scanCache.destroyedTenants;
449
451
  }
@@ -557,7 +559,7 @@ function isLifecycleEventOf(eventType, aggregateType) {
557
559
  if (!eventType.startsWith(`${aggregateType}.`))
558
560
  return false;
559
561
  const verb = eventType.slice(aggregateType.length + 1);
560
- return LIFECYCLE_VERBS.includes(verb);
562
+ return isOneOf(LIFECYCLE_VERBS, verb);
561
563
  }
562
564
  // created events carry the fields flat; updated carries { changes, previous };
563
565
  // deleted/forgotten/restored carry { previous }. Returned sections are the
@@ -5,6 +5,6 @@ export async function acquireEsOpsAdvisoryLock(db) {
5
5
  await asRawClient(db).unsafe(`SELECT pg_advisory_xact_lock($1)`, [ES_OPS_ADVISORY_LOCK_KEY]);
6
6
  }
7
7
  export async function esOperationExists(db, operationId) {
8
- const rows = (await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_es_operations" WHERE id = $1 LIMIT 1`, [operationId]));
8
+ const rows = await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_es_operations" WHERE id = $1 LIMIT 1`, [operationId]);
9
9
  return rows.length > 0;
10
10
  }
@@ -5,14 +5,14 @@ import { asRawClient } from "../query.js";
5
5
  // unassigned one. Both travel as strings — bigint/xid8 doesn't round-trip
6
6
  // through the driver as a JS number.
7
7
  export async function selectSnapshotXmin(db) {
8
- const rows = (await asRawClient(db).unsafe(`SELECT pg_snapshot_xmin(pg_current_snapshot())::text AS xmin`));
8
+ const rows = await asRawClient(db).unsafe(`SELECT pg_snapshot_xmin(pg_current_snapshot())::text AS xmin`);
9
9
  const xmin = rows[0]?.xmin;
10
10
  if (xmin === undefined)
11
11
  throw new Error("selectSnapshotXmin: no row returned");
12
12
  return xmin;
13
13
  }
14
14
  export async function selectSnapshotXmax(db) {
15
- const rows = (await asRawClient(db).unsafe(`SELECT pg_snapshot_xmax(pg_current_snapshot())::text AS xmax`));
15
+ const rows = await asRawClient(db).unsafe(`SELECT pg_snapshot_xmax(pg_current_snapshot())::text AS xmax`);
16
16
  const xmax = rows[0]?.xmax;
17
17
  if (xmax === undefined)
18
18
  throw new Error("selectSnapshotXmax: no row returned");
@@ -36,7 +36,7 @@ export async function lockEventConsumersShareMode(db) {
36
36
  // is still in flight, which pg_snapshot_xip being empty rules out, so the window
37
37
  // scan is skipped then.
38
38
  export async function selectEventIdHorizonWithMissingRanges(db) {
39
- const rows = (await asRawClient(db).unsafe(`WITH a AS (
39
+ const rows = await asRawClient(db).unsafe(`WITH a AS (
40
40
  SELECT "last_processed_event_id" AS cursor, "pending_gaps" AS gaps
41
41
  FROM "kumiko_event_consumers"
42
42
  ORDER BY "last_processed_event_id" DESC
@@ -64,7 +64,7 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
64
64
  r.t::text AS gap_to,
65
65
  r.x AS gap_xmax
66
66
  FROM b LEFT JOIN r ON true
67
- ORDER BY r.f`));
67
+ ORDER BY r.f`);
68
68
  const first = rows[0];
69
69
  if (first === undefined)
70
70
  throw new Error("selectEventIdHorizonWithMissingRanges: no row returned");
@@ -89,7 +89,7 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
89
89
  // subquery only ever affects the row this statement inserts.
90
90
  export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "beginning") {
91
91
  if (startFrom === "now") {
92
- const existing = (await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`, [name, instanceId]));
92
+ const existing = await asRawClient(db).unsafe(`SELECT 1 FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2`, [name, instanceId]);
93
93
  // skip: already registered — an existing cursor is never re-seeded.
94
94
  if (existing.length > 0)
95
95
  return;
@@ -116,18 +116,18 @@ export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "
116
116
  // cycle. The
117
117
  // type-only import keeps the parameter pinned to the "dead" literal.
118
118
  export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
119
- const rows = (await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
119
+ const rows = await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
120
120
  FROM "kumiko_event_consumers" c
121
121
  WHERE ("name", "instance_id") = ANY (SELECT * FROM unnest($1::text[], $2::text[]))
122
122
  AND "status" != $3
123
123
  AND "pending_gaps" = '[]'::jsonb
124
124
  AND NOT EXISTS (
125
125
  SELECT 1 FROM "kumiko_events" e WHERE e."id" > c."last_processed_event_id"
126
- )`, [names, instanceIds, deadStatus]));
126
+ )`, [names, instanceIds, deadStatus]);
127
127
  return rows.map((r) => ({ name: r.name, instanceId: r.instance_id }));
128
128
  }
129
129
  export async function selectConsumerForUpdateSkipLocked(db, name, instanceId) {
130
- const rows = (await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE SKIP LOCKED`, [name, instanceId]));
130
+ const rows = await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE SKIP LOCKED`, [name, instanceId]);
131
131
  return rows[0];
132
132
  }
133
133
  export async function markConsumerProcessing(db, name, instanceId) {
@@ -194,15 +194,15 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
194
194
  ]);
195
195
  }
196
196
  export async function updateConsumerStatusReturning(db, name, instanceId, status) {
197
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
197
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
198
198
  WHERE "name" = $2 AND "instance_id" = $3
199
- RETURNING *`, [status, name, instanceId]));
199
+ RETURNING *`, [status, name, instanceId]);
200
200
  return rows[0];
201
201
  }
202
202
  export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId,
203
203
  // Full list, including ids skipped over between the old cursor and eventId that were not visible yet.
204
204
  pendingGaps) {
205
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
205
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
206
206
  "last_processed_event_id" = $1,
207
207
  "status" = 'idle',
208
208
  "attempts" = 0,
@@ -213,7 +213,7 @@ pendingGaps) {
213
213
  "pending_gaps" = $4::text::jsonb,
214
214
  "updated_at" = now()
215
215
  WHERE "name" = $2 AND "instance_id" = $3
216
- RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)]));
216
+ RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)]);
217
217
  return rows[0];
218
218
  }
219
219
  export async function resetConsumerForMspRebuild(db, name, instanceId) {
@@ -229,7 +229,7 @@ export async function resetConsumerForMspRebuild(db, name, instanceId) {
229
229
  "updated_at" = now()`, [name, instanceId]);
230
230
  }
231
231
  export async function selectConsumerForUpdate(db, name, instanceId) {
232
- const rows = (await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE`, [name, instanceId]));
232
+ const rows = await asRawClient(db).unsafe(`SELECT * FROM "kumiko_event_consumers" WHERE "name" = $1 AND "instance_id" = $2 FOR UPDATE`, [name, instanceId]);
233
233
  return rows[0];
234
234
  }
235
235
  export async function updateConsumerRebuildCursor(db, name, instanceId, lastProcessedEventId, pendingGaps) {
@@ -254,14 +254,14 @@ export async function markConsumerRebuildFailed(db, name, instanceId, errorMessa
254
254
  // so the caller can enforce a lifetime cap on automatic revivals; manual
255
255
  // restartConsumer()/enableConsumer() reset it back to 0.
256
256
  export async function rearmDeadConsumer(db, name, instanceId) {
257
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
257
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
258
258
  "status" = 'idle',
259
259
  "attempts" = 0,
260
260
  "last_error" = NULL,
261
261
  "rearm_count" = "rearm_count" + 1,
262
262
  "updated_at" = now()
263
263
  WHERE "name" = $1 AND "instance_id" = $2
264
- RETURNING *`, [name, instanceId]));
264
+ RETURNING *`, [name, instanceId]);
265
265
  return rows[0];
266
266
  }
267
267
  // skipPoisonEvent's pending-gap branch: the poison is a pending id below the
@@ -269,7 +269,7 @@ export async function rearmDeadConsumer(db, name, instanceId) {
269
269
  // last_processed_event_id (advancing it here would be a regression — the
270
270
  // cursor already sits above this id).
271
271
  export async function removePendingGapReturning(db, name, instanceId, newPendingGaps) {
272
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
272
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
273
273
  "pending_gaps" = $1::text::jsonb,
274
274
  "status" = 'idle',
275
275
  "attempts" = 0,
@@ -278,6 +278,6 @@ export async function removePendingGapReturning(db, name, instanceId, newPending
278
278
  "last_failed_event_id" = NULL,
279
279
  "updated_at" = now()
280
280
  WHERE "name" = $2 AND "instance_id" = $3
281
- RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId]));
281
+ RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId]);
282
282
  return rows[0];
283
283
  }
@@ -18,7 +18,7 @@ export async function insertRawFirstEvent(db, params) {
18
18
  ]);
19
19
  }
20
20
  export async function insertRawSubsequentEvent(db, params) {
21
- const rows = (await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
21
+ const rows = await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
22
22
  aggregate_id, aggregate_type, tenant_id, version,
23
23
  type, event_version, payload, metadata, created_at, created_by
24
24
  )
@@ -41,7 +41,7 @@ export async function insertRawSubsequentEvent(db, params) {
41
41
  params.createdAt,
42
42
  params.createdBy,
43
43
  params.expectedVersion,
44
- ]));
44
+ ]);
45
45
  return rows.length > 0;
46
46
  }
47
47
  export async function insertRawEventBatch(db, sqlValues, params) {
@@ -52,18 +52,18 @@ export async function insertRawEventBatch(db, sqlValues, params) {
52
52
  VALUES ${sqlValues}`, params);
53
53
  }
54
54
  export async function eventPredecessorExists(db, aggregateId, tenantId, version) {
55
- const rows = (await asRawClient(db).unsafe(`SELECT EXISTS(
55
+ const rows = await asRawClient(db).unsafe(`SELECT EXISTS(
56
56
  SELECT 1 FROM "kumiko_events"
57
57
  WHERE aggregate_id = $1::uuid
58
58
  AND tenant_id = $2::uuid
59
59
  AND version = $3
60
- ) AS present`, [aggregateId, tenantId, version]));
60
+ ) AS present`, [aggregateId, tenantId, version]);
61
61
  return rows[0]?.present === true;
62
62
  }
63
63
  export async function findExistingEventVersion(db, sqlInClause, params) {
64
- const rows = (await asRawClient(db).unsafe(`SELECT aggregate_id, version FROM "kumiko_events"
64
+ const rows = await asRawClient(db).unsafe(`SELECT aggregate_id, version FROM "kumiko_events"
65
65
  WHERE (tenant_id, aggregate_id, version) IN (${sqlInClause})
66
- LIMIT 1`, params));
66
+ LIMIT 1`, params);
67
67
  const row = rows[0];
68
68
  if (!row)
69
69
  return undefined;
@@ -7,9 +7,9 @@ import { asRawClient } from "../query.js";
7
7
  // Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
8
8
  // per-event-row hits in-process instead of relying on invalid SQL.
9
9
  export async function transferEventRows(db, params) {
10
- const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
10
+ const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
11
11
  `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4) ` +
12
- `RETURNING "aggregate_id" AS id`, [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds]));
12
+ `RETURNING "aggregate_id" AS id`, [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds]);
13
13
  return [...new Set(rows.map((row) => row.id))];
14
14
  }
15
15
  // Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
@@ -139,7 +139,7 @@ function duplicateIdempotencyKeyErrorOr(e) {
139
139
  }
140
140
  /** INSERT … SELECT … WHERE EXISTS predecessor — stays raw (typed builder can't express this). */
141
141
  export async function insertSubsequentEventRow(db, params) {
142
- const rows = (await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
142
+ const rows = await asRawClient(db).unsafe(`INSERT INTO "kumiko_events" (
143
143
  aggregate_id, aggregate_type, tenant_id, version,
144
144
  type, event_version, payload, metadata, created_by, created_at
145
145
  )
@@ -164,7 +164,7 @@ export async function insertSubsequentEventRow(db, params) {
164
164
  params.createdBy,
165
165
  params.expectedVersion,
166
166
  params.createdAt ?? null,
167
- ]));
167
+ ]);
168
168
  return rows[0];
169
169
  }
170
170
  export async function selectStreamMaxVersion(db, aggregateId, tenantId) {
@@ -1,8 +1,8 @@
1
1
  import { asRawClient } from "../query.js";
2
2
  export async function readPiiBackfillState(db, fingerprint) {
3
- const rows = (await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
3
+ const rows = await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
4
4
  "failed_event_ids"::text[] AS "failedEventIds"
5
- FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint]));
5
+ FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint]);
6
6
  return rows[0] ?? null;
7
7
  }
8
8
  export async function persistPiiBackfillState(db, fingerprint, update) {
@@ -3,7 +3,7 @@ import { asRawClient, unsafeReadRetrying } from "../query.js";
3
3
  * retrying an ambiguous write risks double-apply (#1358). Prefer {@link executeRawQueryRead}
4
4
  * for SELECT-only paths. */
5
5
  export async function executeRawQuery(db, sqlText, params = []) {
6
- return (await asRawClient(db).unsafe(sqlText, params));
6
+ return await asRawClient(db).unsafe(sqlText, params);
7
7
  }
8
8
  /** SELECT-only escape hatch with the #1163 closed-connection retry. Do not pass
9
9
  * INSERT/UPDATE/DELETE — retry re-executes the statement. */
@@ -9,7 +9,7 @@ export async function truncateTablesRestartIdentity(db, tableNames) {
9
9
  await asRawClient(db).unsafe(`TRUNCATE ${quoted} RESTART IDENTITY CASCADE`);
10
10
  }
11
11
  export async function databaseExists(db, dbName) {
12
- const rows = (await asRawClient(db).unsafe(`SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists`, [dbName]));
12
+ const rows = await asRawClient(db).unsafe(`SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists`, [dbName]);
13
13
  return rows[0]?.exists === true;
14
14
  }
15
15
  export async function createDatabase(db, dbName) {
@@ -1,11 +1,9 @@
1
- import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
1
  import { fetchOne, insertOne, updateMany } from "../db/query.js";
3
2
  import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
3
+ import { tableColumnsOf } from "./table-introspection.js";
4
4
  function hasColumn(table, field) {
5
- const cols = table[KUMIKO_COLUMNS_SYMBOL];
6
- if (typeof cols !== "object" || cols === null)
7
- return false;
8
- return field in cols;
5
+ const cols = tableColumnsOf(table);
6
+ return cols !== undefined && field in cols;
9
7
  }
10
8
  /**
11
9
  * Seed reference data at boot time.
@@ -1,15 +1,16 @@
1
1
  import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { toSnakeCase } from "../utils/case.js";
3
3
  import { assertUnreachable } from "../utils/index.js";
4
+ import { columnHandleName } from "./table-introspection.js";
4
5
  export { toSnakeCase } from "../utils/case.js";
5
- import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
6
+ import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, isSqlExpression, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
6
7
  // Partial-index predicate for soft-delete entities; table-builder and
7
8
  // entity-table-meta must render identical index definitions.
8
9
  export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
9
10
  // Shared by the CRUD executor and the parent-ref read-gate — a field's
10
11
  // declared column name if it has one, else its snake_case default.
11
12
  export function physicalColumnName(table, field) {
12
- return table[field]?.name ?? toSnakeCase(field);
13
+ return columnHandleName(table, field) ?? toSnakeCase(field);
13
14
  }
14
15
  // Returns column(s) for a field. Most fields return a single entry,
15
16
  // money returns two (amount + currency).
@@ -281,7 +282,7 @@ export function declareGlobalTenancy(table) {
281
282
  ...meta,
282
283
  tenancy: "global",
283
284
  };
284
- // @cast-boundary type-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
285
+ // @cast-boundary phantom-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
285
286
  return table;
286
287
  }
287
288
  export function buildEntityTable(entityName, entity, options) {
@@ -377,7 +378,8 @@ export function buildEntityTable(entityName, entity, options) {
377
378
  // entity.indexes[].where is now a SqlExpression (was drizzle SQL).
378
379
  // Pass through to the IndexBuilderWithCols.where()-API.
379
380
  if (def.where !== undefined) {
380
- chain = chain.where(def.where);
381
+ if (isSqlExpression(def.where))
382
+ chain = chain.where(def.where);
381
383
  }
382
384
  else if (def.unique === true && entity.softDelete === true) {
383
385
  // Soft-deleted rows keep their value, so a full unique index would
@@ -0,0 +1,16 @@
1
+ import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
+ export declare function isEntityTableMeta(value: unknown): value is EntityTableMeta;
3
+ export declare function asEntityTableMeta(table: unknown): EntityTableMeta | undefined;
4
+ export declare function requireEntityTableMeta(table: unknown, tableName: string): EntityTableMeta;
5
+ /** Physical table name from the unshadowable name symbol; undefined when absent. */
6
+ export declare function schemaTableName(table: unknown): string | undefined;
7
+ /** Name symbol first, then the canonical meta; "<unknown>" when neither resolves. */
8
+ export declare function tableNameOf(table: unknown): string;
9
+ /** Column-handle map stamped by table(); undefined for tables without one. */
10
+ export declare function tableColumnsOf(table: unknown): Record<string, unknown> | undefined;
11
+ /** SQL name carried by the enumerable column handle `table[field]`; undefined when the field has none. */
12
+ export declare function columnHandleName(table: unknown, field: string): string | undefined;
13
+ /** True when `table[field]` is defined, i.e. the table exposes a column handle (or plain value) for the field. */
14
+ export declare function hasFieldProperty(table: unknown, field: string): boolean;
15
+ /** SQL column name of a JS field via the column-handle map; undefined when not mapped. */
16
+ export declare function columnSqlNameOf(table: unknown, field: string): string | undefined;