@cosmicdrift/kumiko-framework 0.363.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 (189) 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 +7 -0
  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/routes.js +46 -17
  80. package/dist/api/server.d.ts +2 -1
  81. package/dist/api/server.js +18 -6
  82. package/dist/api/session-cookies.d.ts +1 -0
  83. package/dist/api/session-cookies.js +20 -5
  84. package/dist/api/sse-route.js +16 -1
  85. package/dist/bun-db/index.d.ts +1 -1
  86. package/dist/bun-db/index.js +1 -1
  87. package/dist/bun-db/query.d.ts +8 -3
  88. package/dist/bun-db/query.js +27 -7
  89. package/dist/db/collect-table-metas.js +11 -7
  90. package/dist/db/eagerload.js +2 -2
  91. package/dist/db/event-store-executor-context.d.ts +5 -6
  92. package/dist/db/event-store-executor-context.js +24 -22
  93. package/dist/db/event-store-executor-read.js +31 -16
  94. package/dist/db/event-store-executor-write.js +18 -20
  95. package/dist/db/index.d.ts +1 -1
  96. package/dist/db/index.js +1 -1
  97. package/dist/db/lock-guard.d.ts +4 -0
  98. package/dist/db/lock-guard.js +14 -0
  99. package/dist/db/migrate-generator.js +1 -1
  100. package/dist/db/migrate-runner.js +9 -8
  101. package/dist/db/queries/event-consumer.d.ts +6 -6
  102. package/dist/db/queries/event-store.d.ts +1 -1
  103. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  104. package/dist/db/tenant-db-runner.d.ts +4 -0
  105. package/dist/db/tenant-db-runner.js +17 -0
  106. package/dist/db/tenant-db.d.ts +2 -0
  107. package/dist/db/tenant-db.js +30 -4
  108. package/dist/derivatives/derivatives-context.js +1 -1
  109. package/dist/engine/boot-validator/access-declarations.js +2 -2
  110. package/dist/engine/boot-validator/api-ext.js +3 -3
  111. package/dist/engine/boot-validator/boot-check.js +1 -1
  112. package/dist/engine/boot-validator/config-deps.js +13 -13
  113. package/dist/engine/boot-validator/detail-screens.js +1 -1
  114. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  115. package/dist/engine/boot-validator/entity-handler.js +84 -35
  116. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  117. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  118. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  119. package/dist/engine/boot-validator/nav.js +1 -1
  120. package/dist/engine/boot-validator/pii-retention.js +8 -8
  121. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  122. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  123. package/dist/engine/boot-validator/screens.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens.js +153 -95
  125. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  126. package/dist/engine/define-handler.d.ts +2 -2
  127. package/dist/engine/define-handler.js +8 -1
  128. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  129. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  130. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  131. package/dist/engine/feature-ast/render.js +10 -2
  132. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  133. package/dist/engine/feature-entity-handlers.js +12 -3
  134. package/dist/engine/handler-field-references.d.ts +21 -2
  135. package/dist/engine/handler-field-references.js +55 -17
  136. package/dist/engine/index.d.ts +2 -2
  137. package/dist/engine/schema-builder.js +2 -1
  138. package/dist/engine/soft-delete-cleanup.js +10 -3
  139. package/dist/engine/steps/retry.js +2 -1
  140. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  141. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  142. package/dist/engine/template-variables.d.ts +3 -0
  143. package/dist/engine/template-variables.js +3 -0
  144. package/dist/engine/types/index.d.ts +1 -1
  145. package/dist/env/_zod-introspect.d.ts +1 -0
  146. package/dist/errors/classes.js +1 -0
  147. package/dist/errors/kumiko-error.d.ts +2 -0
  148. package/dist/errors/kumiko-error.js +2 -0
  149. package/dist/errors/write-error-info.js +2 -0
  150. package/dist/event-store/tenant-db-append.js +2 -2
  151. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  152. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  153. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  154. package/dist/files/bind-pending-file-refs.js +3 -3
  155. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  156. package/dist/files/bind-referenced-files.write.js +22 -13
  157. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  158. package/dist/files/create-file-ref-from-bytes.js +67 -4
  159. package/dist/files/create-file-ref.d.ts +22 -0
  160. package/dist/files/create-file-ref.js +53 -25
  161. package/dist/files/file-handle.d.ts +1 -1
  162. package/dist/files/file-handle.js +8 -0
  163. package/dist/files/file-routes.js +1 -1
  164. package/dist/files/index.d.ts +2 -2
  165. package/dist/files/index.js +1 -1
  166. package/dist/files/load-save-file-refs.d.ts +10 -0
  167. package/dist/files/load-save-file-refs.js +47 -0
  168. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  169. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  170. package/dist/jobs/index.d.ts +1 -1
  171. package/dist/jobs/job-runner.d.ts +4 -1
  172. package/dist/jobs/job-runner.js +8 -4
  173. package/dist/observability/escape-hatch-report.d.ts +1 -1
  174. package/dist/observability/escape-hatch-report.js +17 -5
  175. package/dist/pipeline/cascade-handler.js +5 -2
  176. package/dist/pipeline/dispatch-batch.js +79 -31
  177. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  178. package/dist/pipeline/dispatch-shared.js +99 -22
  179. package/dist/pipeline/dispatch-write.js +34 -22
  180. package/dist/pipeline/index.d.ts +1 -0
  181. package/dist/pipeline/index.js +1 -0
  182. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  183. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  184. package/dist/rate-limit/bucket.d.ts +3 -3
  185. package/dist/rate-limit/bucket.js +14 -4
  186. package/dist/redis/cache-sync-bus.js +9 -3
  187. package/dist/search/meilisearch-adapter.js +36 -9
  188. package/package.json +7 -5
  189. package/src/changes.json +207 -8
@@ -21,7 +21,7 @@
21
21
  // Static import (not getTemporal()): coercion works without an installed
22
22
  // global and returns the same Temporal classes as the rest of the repo (#1480).
23
23
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
24
- import { isOneOf, isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
24
+ import { isOneOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
25
25
  import { requestContext } from "../api/request-context.js";
26
26
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
27
27
  import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
@@ -50,6 +50,9 @@ function snakeToCamel(key) {
50
50
  // would overwrite the matching meta key — reading the meta from the symbol is
51
51
  // the only collision-safe path.
52
52
  export { asEntityTableMeta, requireEntityTableMeta };
53
+ export function hasBegin(client) {
54
+ return typeof client.begin === "function";
55
+ }
53
56
  function isRawClient(value) {
54
57
  return typeof propertyOf(value, "unsafe") === "function";
55
58
  }
@@ -77,6 +80,9 @@ export function asRawClient(db) {
77
80
  return sessionClient;
78
81
  throw new Error("bun-db: db argument has no .unsafe() — pass Bun.SQL, postgres-js Sql, or a transaction handle.");
79
82
  }
83
+ export function runnerCanBeginTransaction(db) {
84
+ return hasBegin(asRawClient(db));
85
+ }
80
86
  // postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
81
87
  // it errored — even if the JS error was caught (the driver tracks per-block
82
88
  // query failures). Error confinement therefore must go through the driver's
@@ -329,8 +335,10 @@ function plainDateFromDriver(value, column) {
329
335
  //
330
336
  // Unknown columns (computed/aliased) pass through unchanged.
331
337
  export function coerceRow(row, info) {
332
- if (!isPlainObject(row))
333
- return row;
338
+ // @cast-boundary db-row — the driver row (raw column names/types) becomes the caller's entity row shape here, and only here
339
+ return coerceDriverRow(row, info);
340
+ }
341
+ function coerceDriverRow(row, info) {
334
342
  const out = {};
335
343
  let changed = false;
336
344
  for (const key of Object.keys(row)) {
@@ -400,7 +408,7 @@ export function coerceRow(row, info) {
400
408
  changed = true;
401
409
  out[fieldName] = coerced;
402
410
  }
403
- return (changed ? out : row);
411
+ return changed ? out : row;
404
412
  }
405
413
  function coerceRows(rows, info) {
406
414
  return rows.map((r) => coerceRow(r, info));
@@ -547,8 +555,14 @@ function buildWhereClause(info, where, startIndex, qualifier) {
547
555
  throw new Error(`where: jsonText requires a jsonb column, "${col}" is ${pgType}`);
548
556
  }
549
557
  const matches = isJsonTextMatchList(value.jsonText) ? value.jsonText : [value.jsonText];
558
+ if (matches.length === 0) {
559
+ conditions.push("FALSE");
560
+ continue;
561
+ }
550
562
  for (const match of matches) {
551
- if (!Array.isArray(match.keys) ||
563
+ if (match === null ||
564
+ typeof match !== "object" ||
565
+ !Array.isArray(match.keys) ||
552
566
  match.keys.length === 0 ||
553
567
  !match.keys.every((k) => typeof k === "string") ||
554
568
  typeof match.eq !== "string") {
@@ -1376,12 +1390,18 @@ export async function deleteManyBatched(db, table, where, options) {
1376
1390
  return { deleted, batches };
1377
1391
  }
1378
1392
  export async function transaction(db, fn) {
1379
- return asRawClient(db).begin(fn);
1393
+ const raw = asRawClient(db);
1394
+ if (!hasBegin(raw)) {
1395
+ throw new InternalError({
1396
+ message: "transaction: runner has no begin() — already inside a transaction or savepoint.",
1397
+ });
1398
+ }
1399
+ return raw.begin(fn);
1380
1400
  }
1381
1401
  // A tx/savepoint handle has no begin(); fail closed instead of nesting silently or TypeError-ing.
1382
1402
  export async function runInNewTransaction(db, fn) {
1383
1403
  const raw = asRawClient(db);
1384
- if (typeof raw.begin !== "function") {
1404
+ if (!hasBegin(raw)) {
1385
1405
  throw new InternalError({
1386
1406
  message: "runInNewTransaction: runner has no begin() — already inside a transaction or savepoint.",
1387
1407
  });
@@ -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-
@@ -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);
@@ -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,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;
@@ -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,24 +306,6 @@ 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(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
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.
@@ -351,6 +338,21 @@ 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).
@@ -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,
@@ -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
@@ -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";
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
+ }
@@ -437,7 +437,7 @@ export function assertUniqueTableNames(metas) {
437
437
  const duplicates = [...counts].filter(([, count]) => count > 1);
438
438
  if (duplicates.length > 0) {
439
439
  throw new Error(duplicates
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 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).`)
441
441
  .join("\n"));
442
442
  }
443
443
  }
@@ -17,7 +17,7 @@ import { createHash } from "node:crypto";
17
17
  import { existsSync, readdirSync, readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
20
- import { asRawClient } from "../bun-db/query.js";
20
+ import { asRawClient, hasBegin } from "../bun-db/query.js";
21
21
  // Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
22
22
  // gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
23
23
  // blockiert bis erster fertig).
@@ -169,17 +169,18 @@ export function readMigrationSqlTexts(dir) {
169
169
  // without it fall through to the drizzle wrappers and otherwise fail here,
170
170
  // before any SQL runs, with the migrate-runner message.
171
171
  function migrationClient(db) {
172
- const hasDirectClient = typeof propertyOf(db, "unsafe") === "function" && typeof propertyOf(db, "begin") === "function";
173
- if (hasDirectClient)
174
- return asRawClient(db);
175
- for (const wrapped of [
172
+ for (const candidate of [
173
+ db,
176
174
  propertyOf(db, "$client"),
177
175
  propertyOf(propertyOf(db, "session"), "client"),
178
176
  ]) {
179
- if (typeof propertyOf(wrapped, "unsafe") === "function")
180
- return asRawClient(wrapped);
177
+ if (typeof propertyOf(candidate, "unsafe") !== "function")
178
+ continue;
179
+ const client = asRawClient(candidate);
180
+ if (hasBegin(client))
181
+ return client;
181
182
  }
182
- throw new Error("migrate-runner: db argument has no .unsafe() (need Bun.SQL or drizzle DbConnection)");
183
+ throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need Bun.SQL or drizzle DbConnection)");
183
184
  }
184
185
  // Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
185
186
  async function executeRaw(db, sqlText) {
@@ -13,7 +13,7 @@ export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readon
13
13
  readonly name: string;
14
14
  readonly instanceId: string;
15
15
  }>>;
16
- export declare function selectConsumerForUpdateSkipLocked<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string): Promise<TRow | undefined>;
16
+ export declare function selectConsumerForUpdateSkipLocked(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
17
17
  export declare function markConsumerProcessing(db: AnyDb, name: string, instanceId: string): Promise<void>;
18
18
  export declare function recordConsumerPassFailure(db: AnyDb, name: string, instanceId: string, errorMessage: string, cursorBeforePass?: bigint): Promise<void>;
19
19
  export type ConsumerDeliveryOutcome = {
@@ -26,11 +26,11 @@ export type ConsumerDeliveryOutcome = {
26
26
  readonly failedEventId: bigint | null;
27
27
  };
28
28
  export declare function updateConsumerDeliveryOutcome(db: AnyDb, name: string, instanceId: string, outcome: ConsumerDeliveryOutcome): Promise<void>;
29
- export declare function updateConsumerStatusReturning<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<TRow | undefined>;
30
- export declare function advanceConsumerPastEventReturning<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<TRow | undefined>;
29
+ export declare function updateConsumerStatusReturning(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<Record<string, unknown> | undefined>;
30
+ export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
31
31
  export declare function resetConsumerForMspRebuild(db: AnyDb, name: string, instanceId: string): Promise<void>;
32
- export declare function selectConsumerForUpdate<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string): Promise<TRow | undefined>;
32
+ export declare function selectConsumerForUpdate(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
33
33
  export declare function updateConsumerRebuildCursor(db: AnyDb, name: string, instanceId: string, lastProcessedEventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<void>;
34
34
  export declare function markConsumerRebuildFailed(db: AnyDb, name: string, instanceId: string, errorMessage: string): Promise<void>;
35
- export declare function rearmDeadConsumer<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string): Promise<TRow | undefined>;
36
- export declare function removePendingGapReturning<TRow = Record<string, unknown>>(db: AnyDb, name: string, instanceId: string, newPendingGaps: readonly PendingGapEntry[]): Promise<TRow | undefined>;
35
+ export declare function rearmDeadConsumer(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
36
+ export declare function removePendingGapReturning(db: AnyDb, name: string, instanceId: string, newPendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
@@ -56,5 +56,5 @@ export type PendingIdRange = {
56
56
  readonly from: bigint;
57
57
  readonly to: bigint;
58
58
  };
59
- export declare function selectPendingAndNewEventRows<TRow = Record<string, unknown>>(db: AnyDb, cursor: bigint, pendingRanges: readonly PendingIdRange[], batchSize: number): Promise<ReadonlyArray<TRow>>;
59
+ export declare function selectPendingAndNewEventRows(db: AnyDb, cursor: bigint, pendingRanges: readonly PendingIdRange[], batchSize: number): Promise<ReadonlyArray<Record<string, unknown>>>;
60
60
  export declare function selectSmallestVisibleIdInRanges(db: AnyDb, pendingRanges: readonly PendingIdRange[]): Promise<bigint | null>;
@@ -1,6 +1,6 @@
1
1
  import type { AnyDb } from "../query.js";
2
2
  export declare function markProjectionRebuilding(db: AnyDb, projectionName: string): Promise<void>;
3
- export declare function selectEventsForProjectionRebuildBatch<TRow = Record<string, unknown>>(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[], afterId: bigint, limit: number): Promise<ReadonlyArray<TRow>>;
3
+ export declare function selectEventsForProjectionRebuildBatch(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[], afterId: bigint, limit: number): Promise<ReadonlyArray<Record<string, unknown>>>;
4
4
  export declare function countSubscribedEvents(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[]): Promise<bigint>;
5
5
  export declare function finalizeProjectionRebuild(db: AnyDb, projectionName: string, lastProcessedEventId: bigint): Promise<void>;
6
6
  export declare function markProjectionRebuildFailed(db: AnyDb, projectionName: string, errorMessage: string): Promise<void>;
@@ -1,4 +1,8 @@
1
1
  import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
+ import { InternalError } from "../errors/index.js";
2
3
  import type { DbRunner } from "./connection.js";
3
4
  export declare function bindTenantDbRunner(tenantDb: TenantDb, runner: DbRunner): void;
4
5
  export declare function tenantDbRunner(tenantDb: TenantDb): DbRunner;
6
+ export declare function denyTenantDbWrites(tenantDb: TenantDb, reason: string): void;
7
+ export declare function tenantDbWritesDeniedError(operation: string, reason: string): InternalError;
8
+ export declare function tenantDbWriteRunner(tenantDb: TenantDb, operation: string): DbRunner;