@cosmicdrift/kumiko-framework 0.364.0 → 0.365.1

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 (163) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/webhook-step.md +11 -1
  22. package/agents/examples/workflow-engine.md +2 -2
  23. package/agents/examples.md +2 -1
  24. package/agents/features.md +4 -4
  25. package/dist/api/auth-routes.d.ts +0 -1
  26. package/dist/api/auth-routes.js +44 -43
  27. package/dist/api/index.d.ts +1 -1
  28. package/dist/api/index.js +1 -1
  29. package/dist/api/server.d.ts +2 -2
  30. package/dist/api/server.js +29 -4
  31. package/dist/bun-db/connection.js +3 -16
  32. package/dist/bun-db/index.d.ts +1 -1
  33. package/dist/bun-db/index.js +1 -1
  34. package/dist/bun-db/query.d.ts +7 -0
  35. package/dist/bun-db/query.js +50 -18
  36. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  37. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  38. package/dist/crypto/owner-transition-rekey.js +7 -3
  39. package/dist/crypto/pg-kms-adapter.js +10 -6
  40. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  41. package/dist/crypto/pii-field-encryption.js +9 -2
  42. package/dist/crypto/subject-resolver.d.ts +1 -0
  43. package/dist/crypto/subject-resolver.js +7 -0
  44. package/dist/db/api.d.ts +4 -3
  45. package/dist/db/apply-entity-event.js +26 -2
  46. package/dist/db/blind-index-cleanup.js +2 -1
  47. package/dist/db/bun-provider.js +9 -18
  48. package/dist/db/dialect.d.ts +1 -1
  49. package/dist/db/dialect.js +2 -0
  50. package/dist/db/eagerload.js +2 -1
  51. package/dist/db/event-store-executor-context.d.ts +1 -0
  52. package/dist/db/event-store-executor-context.js +1 -0
  53. package/dist/db/event-store-executor-write.d.ts +1 -1
  54. package/dist/db/event-store-executor-write.js +85 -58
  55. package/dist/db/index.d.ts +1 -0
  56. package/dist/db/index.js +1 -0
  57. package/dist/db/migrate-runner.d.ts +1 -0
  58. package/dist/db/migrate-runner.js +56 -31
  59. package/dist/db/queries/backfill-pii.js +63 -25
  60. package/dist/db/queries/event-consumer.js +2 -1
  61. package/dist/db/queries/event-store-transfer.js +15 -4
  62. package/dist/db/queries/event-store.js +11 -10
  63. package/dist/db/queries/pii-backfill-state.js +3 -2
  64. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  65. package/dist/db/queries/postgres-array-literal.js +6 -0
  66. package/dist/db/queries/projection-rebuild.js +3 -2
  67. package/dist/db/queries/shadow-swap.d.ts +1 -1
  68. package/dist/db/queries/shadow-swap.js +12 -8
  69. package/dist/db/query.d.ts +1 -1
  70. package/dist/db/query.js +1 -1
  71. package/dist/db/retired-framework-tables.js +22 -4
  72. package/dist/db/table-builder.js +6 -2
  73. package/dist/db/tenant-db.d.ts +2 -0
  74. package/dist/db/tenant-db.js +20 -1
  75. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  76. package/dist/derivatives/delete-stored-file.js +9 -0
  77. package/dist/derivatives/index.d.ts +1 -1
  78. package/dist/derivatives/index.js +1 -1
  79. package/dist/engine/boot-validator/entity-handler.js +3 -0
  80. package/dist/engine/boot-validator/query-refs.js +14 -2
  81. package/dist/engine/dedupe-features.js +31 -4
  82. package/dist/engine/entity-handlers.js +14 -7
  83. package/dist/engine/index.d.ts +2 -2
  84. package/dist/engine/index.js +1 -1
  85. package/dist/engine/registry-validate.d.ts +1 -0
  86. package/dist/engine/registry-validate.js +21 -0
  87. package/dist/engine/registry.js +2 -1
  88. package/dist/engine/screen-helpers.d.ts +1 -0
  89. package/dist/engine/screen-helpers.js +2 -2
  90. package/dist/engine/steps/_read-source.js +5 -2
  91. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  92. package/dist/engine/tier-resolver-extension.js +6 -0
  93. package/dist/engine/types/index.d.ts +1 -1
  94. package/dist/entrypoint/index.d.ts +2 -2
  95. package/dist/errors/docs-url.js +6 -6
  96. package/dist/errors/index.d.ts +1 -1
  97. package/dist/errors/index.js +1 -1
  98. package/dist/errors/subject-erased.d.ts +1 -0
  99. package/dist/errors/subject-erased.js +6 -0
  100. package/dist/event-store/admin-api.js +3 -2
  101. package/dist/event-store/errors.d.ts +9 -0
  102. package/dist/event-store/errors.js +20 -0
  103. package/dist/event-store/event-store.d.ts +6 -0
  104. package/dist/event-store/event-store.js +28 -6
  105. package/dist/event-store/index.d.ts +1 -1
  106. package/dist/event-store/index.js +1 -1
  107. package/dist/files/create-file-ref.d.ts +2 -1
  108. package/dist/files/create-file-ref.js +16 -2
  109. package/dist/files/dispose-removed-file-refs.js +21 -6
  110. package/dist/files/feature.d.ts +1 -0
  111. package/dist/files/feature.js +2 -1
  112. package/dist/files/file-handle.js +4 -0
  113. package/dist/files/file-routes.js +9 -5
  114. package/dist/files/index.d.ts +1 -1
  115. package/dist/files/index.js +1 -1
  116. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  117. package/dist/files/resolve-content-type.js +40 -4
  118. package/dist/files/storage-tracking.js +5 -4
  119. package/dist/files/types.js +5 -8
  120. package/dist/files/zip-entry-names.d.ts +1 -0
  121. package/dist/files/zip-entry-names.js +46 -0
  122. package/dist/jobs/job-runner.d.ts +2 -0
  123. package/dist/jobs/job-runner.js +183 -48
  124. package/dist/jobs/max-per-tenant.d.ts +8 -0
  125. package/dist/jobs/max-per-tenant.js +14 -0
  126. package/dist/observability/escape-hatch-report.d.ts +1 -0
  127. package/dist/observability/escape-hatch-report.js +31 -2
  128. package/dist/observability/index.d.ts +1 -1
  129. package/dist/observability/index.js +1 -1
  130. package/dist/observability/metrics-handle.d.ts +1 -0
  131. package/dist/observability/metrics-handle.js +7 -0
  132. package/dist/observability/standard-metrics.js +4 -4
  133. package/dist/pipeline/dispatch-query.d.ts +2 -1
  134. package/dist/pipeline/dispatch-query.js +6 -6
  135. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  136. package/dist/pipeline/dispatch-shared.js +41 -30
  137. package/dist/pipeline/dispatch-stream.js +10 -4
  138. package/dist/pipeline/dispatcher.js +3 -1
  139. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  140. package/dist/pipeline/event-dispatcher.js +11 -9
  141. package/dist/pipeline/event-retention.d.ts +1 -0
  142. package/dist/pipeline/event-retention.js +7 -1
  143. package/dist/pipeline/index.d.ts +2 -0
  144. package/dist/pipeline/index.js +1 -0
  145. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  146. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  147. package/dist/pipeline/notify-scope.js +3 -1
  148. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  149. package/dist/schema-cli.js +12 -1
  150. package/dist/stack/db.d.ts +2 -2
  151. package/dist/stack/test-stack.js +2 -1
  152. package/dist/testing/index.d.ts +1 -0
  153. package/dist/testing/index.js +1 -0
  154. package/dist/testing/multipart-helper.d.ts +6 -0
  155. package/dist/testing/multipart-helper.js +1 -1
  156. package/dist/testing/zip-fixture.d.ts +1 -0
  157. package/dist/testing/zip-fixture.js +33 -0
  158. package/dist/ui-types/card-column-roles.js +4 -2
  159. package/dist/ui-types/index.d.ts +1 -1
  160. package/dist/ui-types/index.js +1 -1
  161. package/dist/upgrade-cli.js +46 -13
  162. package/package.json +4 -4
  163. package/src/changes.json +323 -0
@@ -43,7 +43,11 @@
43
43
  // unresolvable subject still fails loud into `failures`, as before.
44
44
  // 4. owner re-key ({@link PiiBackfillOptions.rekeyTenantKeyedOwnerFields}) —
45
45
  // tenant-keyed ciphertext of a userOwned field moves under the user the
46
- // strict chain (stages 1+2, never whenAbsent) names.
46
+ // strict chain (stages 1+2, never whenAbsent) names; user-keyed ciphertext
47
+ // moves under the projection's current owner when that is another user (an
48
+ // owner change made before owner transitions re-keyed the value). An owner
49
+ // cleared again (owner → none) is not recoverable from the projection: such
50
+ // values stay tenant-keyed, unlike the live path that keeps the last owner.
47
51
  //
48
52
  // Snapshots of touched aggregates are dropped per batch (they may cache
49
53
  // plaintext); the next snapshotting load recreates them. AFTER a run, rebuild the
@@ -54,11 +58,13 @@ import { asRawClient } from "../../bun-db/index.js";
54
58
  import { quoteIdent } from "../../crypto/ciphertext-pattern.js";
55
59
  import { configuredEventPiiCatalog } from "../../crypto/event-pii.js";
56
60
  import { isLocalKeyKmsAdapter, KeyErasedError, KeyNotFoundError, } from "../../crypto/kms-adapter.js";
61
+ import { isPlainUserOwnedField } from "../../crypto/owner-transition-rekey.js";
57
62
  import { configuredPiiSubjectKms, decryptPiiValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, piiCiphertextSubject, } from "../../crypto/pii-field-encryption.js";
58
- import { collectPiiEncryptedFields, resolveEventSubject, resolveSubjectForField, SubjectResolutionError, } from "../../crypto/subject-resolver.js";
63
+ import { collectPiiEncryptedFields, nonEmptyString, resolveEventSubject, resolveSubjectForField, SubjectResolutionError, } from "../../crypto/subject-resolver.js";
59
64
  import { resolveTableName } from "../entity-table-meta.js";
60
65
  import { tableExists } from "../schema-inspection.js";
61
66
  import { toSnakeCase } from "../table-builder.js";
67
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
62
68
  const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
63
69
  function collectPiiTargets(registry) {
64
70
  const entityTargets = new Map();
@@ -154,12 +160,21 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
154
160
  ? raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
155
161
  FROM "kumiko_events"
156
162
  WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" = ANY($3::bigint[])
157
- ORDER BY "id" ASC`, [aggregateTypes, catalogTypes, [...options.eventIds]])
163
+ ORDER BY "id" ASC`, [
164
+ toPostgresArrayLiteral(aggregateTypes),
165
+ toPostgresArrayLiteral(catalogTypes),
166
+ toPostgresArrayLiteral([...options.eventIds]),
167
+ ])
158
168
  : raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
159
169
  FROM "kumiko_events"
160
170
  WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
161
171
  ORDER BY "id" ASC
162
- LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize]));
172
+ LIMIT $4`, [
173
+ toPostgresArrayLiteral(aggregateTypes),
174
+ toPostgresArrayLiteral(catalogTypes),
175
+ String(options.afterEventId ?? 0),
176
+ batchSize,
177
+ ]));
163
178
  const last = rows[rows.length - 1];
164
179
  if (last === undefined)
165
180
  return emptyBatch();
@@ -205,7 +220,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
205
220
  }
206
221
  // Snapshots may cache the plaintext state of touched aggregates.
207
222
  if (!options.dryRun && touchedAggregates.size > 0) {
208
- const deleted = await raw.unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [[...touchedAggregates]]);
223
+ const deleted = await raw.unsafe(`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`, [toPostgresArrayLiteral([...touchedAggregates])]);
209
224
  result.deletedSnapshots = deleted.length;
210
225
  }
211
226
  return {
@@ -250,7 +265,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
250
265
  // aborts the surrounding transaction (25P02). Entity never mounted/rebuilt
251
266
  // → stage 3 still gets a chance; this batch just contributes no owners.
252
267
  if (await tableExists(db, `public.${tableName}`)) {
253
- const projectionRows = await raw.unsafe(`SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`, [[...ids]]);
268
+ const projectionRows = await raw.unsafe(`SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`, [toPostgresArrayLiteral([...ids])]);
254
269
  for (const projectionRow of projectionRows) {
255
270
  const id = projectionRow["id"];
256
271
  if (typeof id === "string") {
@@ -456,24 +471,20 @@ function projectionOwnerRowOfTenant(owner, tenantId) {
456
471
  return undefined;
457
472
  return owner.tenantId === undefined || owner.tenantId === tenantId ? owner.fields : undefined;
458
473
  }
459
- function isPlainUserOwnedField(entity, field) {
460
- const fieldDef = entity.fields[field];
461
- if (fieldDef === undefined || !("userOwned" in fieldDef) || fieldDef.userOwned === undefined) {
462
- return false;
463
- }
464
- return !("recordOwned" in fieldDef && fieldDef.recordOwned === true);
465
- }
466
474
  // Stage 4. In dryRun the "rekeyed" value is the untouched ciphertext (nothing is written).
467
475
  async function rekeyTenantKeyedValue(deps, target) {
468
476
  const { entity, field, ciphertext } = target;
469
- if (!isPlainUserOwnedField(entity, field) || !isTenantKeyedUnder(ciphertext, target.tenantId)) {
477
+ const fieldDef = entity.fields[field];
478
+ if (fieldDef === undefined || !isPlainUserOwnedField(fieldDef))
470
479
  return { kind: "unchanged" };
471
- }
472
- const resolution = resolveOwnerSubject(entity, field, target.subjectSource, target.ownerFallback, target.projectionOwnerRow, target.tenantId, target.aggregateType);
473
- if (resolution.kind !== "resolved" || resolution.subject.kind !== "user") {
480
+ const keyedUnder = ciphertextSubjectOrNull(ciphertext);
481
+ const userSubject = keyedUnder?.kind === "tenant" && keyedUnder.tenantId === target.tenantId
482
+ ? ownerOfTenantKeyedValue(target)
483
+ : keyedUnder?.kind === "user"
484
+ ? currentProjectionOwnerIfOther(target, keyedUnder.userId)
485
+ : null;
486
+ if (userSubject === null)
474
487
  return { kind: "unchanged" };
475
- }
476
- const userSubject = resolution.subject;
477
488
  if (await deps.isForgottenSubject(userSubject))
478
489
  return { kind: "erased" };
479
490
  if (deps.dryRun) {
@@ -494,16 +505,32 @@ async function rekeyTenantKeyedValue(deps, target) {
494
505
  throw e;
495
506
  }
496
507
  }
497
- // Only the row's own tenant key counts: another tenant's or a user's key is
508
+ // Strict chain (payload, then projection), never whenAbsent.
509
+ function ownerOfTenantKeyedValue(target) {
510
+ const resolution = resolveOwnerSubject(target.entity, target.field, target.subjectSource, target.ownerFallback, target.projectionOwnerRow, target.tenantId, target.aggregateType);
511
+ return resolution.kind === "resolved" && resolution.subject.kind === "user"
512
+ ? resolution.subject
513
+ : null;
514
+ }
515
+ // A value keyed under a previous owner moves to the owner the row has now. The
516
+ // projection of the event's own tenant is authoritative here: the payload of
517
+ // an old created event still names the owner the row had back then.
518
+ function currentProjectionOwnerIfOther(target, keyedUserId) {
519
+ const fieldDef = target.entity.fields[target.field];
520
+ if (fieldDef === undefined || !("userOwned" in fieldDef) || !fieldDef.userOwned)
521
+ return null;
522
+ const owner = nonEmptyString(target.projectionOwnerRow?.[fieldDef.userOwned.ownerField]);
523
+ return owner === null || owner === keyedUserId ? null : { kind: "user", userId: owner };
524
+ }
525
+ // Only the row's own tenant key or a user key counts: another tenant's key is
498
526
  // never decrypted here.
499
- function isTenantKeyedUnder(ciphertext, tenantId) {
527
+ function ciphertextSubjectOrNull(ciphertext) {
500
528
  try {
501
- const subject = piiCiphertextSubject(ciphertext);
502
- return subject.kind === "tenant" && subject.tenantId === tenantId;
529
+ return piiCiphertextSubject(ciphertext);
503
530
  }
504
531
  catch {
505
532
  // skip: a malformed ciphertext prefix is not ours to re-key
506
- return false;
533
+ return null;
507
534
  }
508
535
  }
509
536
  function attemptResolve(entity, field, source, opts) {
@@ -516,6 +543,17 @@ function attemptResolve(entity, field, source, opts) {
516
543
  return { ok: false, error: e };
517
544
  }
518
545
  }
546
+ // An explicit owner key (even null) means the event itself knows the row had no
547
+ // owner then; only a missing key predates the owner field and may use the
548
+ // projection's current owner.
549
+ function payloadNamesOwnerKey(entity, field, subjectSource, ownerFallback) {
550
+ const fieldDef = entity.fields[field];
551
+ if (fieldDef === undefined || !("userOwned" in fieldDef) || !fieldDef.userOwned)
552
+ return false;
553
+ const { ownerField } = fieldDef.userOwned;
554
+ return (Object.hasOwn(subjectSource, ownerField) ||
555
+ (ownerFallback !== undefined && Object.hasOwn(ownerFallback, ownerField)));
556
+ }
519
557
  // Stage 1 (payload) and stage 2 (projection row, when supplied) resolve the
520
558
  // owner strictly, ignoring whenAbsent — an ownerless payload must not shadow
521
559
  // an owner the projection knows. Stage 2b then applies whenAbsent to the
@@ -529,7 +567,7 @@ function resolveOwnerSubject(entity, field, subjectSource, ownerFallback, projec
529
567
  ? { kind: "unannotated" }
530
568
  : { kind: "resolved", subject: payloadAttempt.subject, viaProjection: false };
531
569
  }
532
- if (projectionOwnerRow) {
570
+ if (projectionOwnerRow && !payloadNamesOwnerKey(entity, field, subjectSource, ownerFallback)) {
533
571
  const augmented = { ...subjectSource, ...projectionOwnerRow };
534
572
  const projectionAttempt = attemptResolve(entity, field, augmented, strictOpts);
535
573
  if (projectionAttempt.ok && projectionAttempt.subject !== null) {
@@ -1,4 +1,5 @@
1
1
  import { asRawClient } from "../query.js";
2
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
2
3
  // Per-turn snapshot bounds for pending-gap finality (event-dispatcher.ts's
3
4
  // processConsumer). pg_current_snapshot() is this transaction's MVCC view;
4
5
  // xmin is the oldest still-in-progress xact id in it, xmax the next
@@ -123,7 +124,7 @@ export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, de
123
124
  AND "pending_gaps" = '[]'::jsonb
124
125
  AND NOT EXISTS (
125
126
  SELECT 1 FROM "kumiko_events" e WHERE e."id" > c."last_processed_event_id"
126
- )`, [names, instanceIds, deadStatus]);
127
+ )`, [toPostgresArrayLiteral(names), toPostgresArrayLiteral(instanceIds), deadStatus]);
127
128
  return rows.map((r) => ({ name: r.name, instanceId: r.instance_id }));
128
129
  }
129
130
  export async function selectConsumerForUpdateSkipLocked(db, name, instanceId) {
@@ -4,12 +4,18 @@
4
4
  // backfill-pii.ts): the one place in the repo allowed to write tenant_id
5
5
  // directly onto kumiko_events / kumiko_snapshots / kumiko_archived_streams.
6
6
  import { asRawClient } from "../query.js";
7
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
7
8
  // Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
8
9
  // per-event-row hits in-process instead of relying on invalid SQL.
9
10
  export async function transferEventRows(db, params) {
10
11
  const rows = await asRawClient(db).unsafe(`UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
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
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4::uuid[]) ` +
13
+ `RETURNING "aggregate_id" AS id`, [
14
+ params.destinationTenantId,
15
+ params.sourceTenantId,
16
+ params.aggregateType,
17
+ toPostgresArrayLiteral(params.aggregateIds),
18
+ ]);
13
19
  return [...new Set(rows.map((row) => row.id))];
14
20
  }
15
21
  // Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
@@ -17,7 +23,7 @@ export async function transferEventRows(db, params) {
17
23
  // events on next read instead of carrying the snapshot's own generation
18
24
  // bookkeeping across the tenant boundary.
19
25
  export async function deleteSnapshotsForAggregates(db, params) {
20
- await asRawClient(db).unsafe(`DELETE FROM "kumiko_snapshots" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2)`, [params.tenantId, params.aggregateIds]);
26
+ await asRawClient(db).unsafe(`DELETE FROM "kumiko_snapshots" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2::uuid[])`, [params.tenantId, toPostgresArrayLiteral(params.aggregateIds)]);
21
27
  }
22
28
  // An archived stream's marker must move with it — leaving it behind would
23
29
  // make the stream writable again the instant it lands in the destination
@@ -27,5 +33,10 @@ export async function deleteSnapshotsForAggregates(db, params) {
27
33
  // moved aggregate's id must stay in the source tenant.
28
34
  export async function transferArchivedStreamRows(db, params) {
29
35
  await asRawClient(db).unsafe(`UPDATE "kumiko_archived_streams" SET "tenant_id" = $1 ` +
30
- `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4)`, [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds]);
36
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4::uuid[])`, [
37
+ params.destinationTenantId,
38
+ params.sourceTenantId,
39
+ params.aggregateType,
40
+ toPostgresArrayLiteral(params.aggregateIds),
41
+ ]);
31
42
  }
@@ -1,5 +1,6 @@
1
1
  import { constraintOf, isLockNotAvailable, isTableAlreadyExists, isUniqueViolation, } from "../pg-error.js";
2
- import { asRawClient, runInNewTransaction, unsafeReadRetrying } from "../query.js";
2
+ import { asRawClient, runInNewTransaction, runnerCanBeginTransaction, unsafeReadRetrying, } from "../query.js";
3
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
3
4
  // Gap-finality (event-dispatcher pending_gaps) needs every holder of an
4
5
  // event id to already have a *real* xact id by the time it inserts — Postgres
5
6
  // assigns those lazily on first write, so a bare INSERT alone doesn't
@@ -12,12 +13,9 @@ export async function claimXactId(db) {
12
13
  // notify would be three commits and the claimed xact id would be released before
13
14
  // the insert's. A pool runner (has begin()) therefore gets one transaction around
14
15
  // the whole write; inside a caller's tx or savepoint the caller's tx already is
15
- // that unit. Bun.SQL's TransactionSQL extends SQL and still exposes begin(), so
16
- // only a runner without savepoint() counts as a pool.
16
+ // that unit.
17
17
  export async function runAtomicEventWrite(db, write) {
18
- const raw = asRawClient(db);
19
- const isPool = typeof raw.begin === "function" && typeof raw.savepoint !== "function";
20
- if (!isPool)
18
+ if (!runnerCanBeginTransaction(db))
21
19
  return write(db);
22
20
  return runInNewTransaction(db, (tx) => write(tx));
23
21
  }
@@ -175,7 +173,7 @@ export async function selectStreamMaxVersions(db, aggregateIds, tenantId) {
175
173
  const versions = new Map();
176
174
  if (aggregateIds.length === 0)
177
175
  return versions;
178
- const rows = await unsafeReadRetrying(db, `SELECT "aggregate_id" AS id, MAX("version") AS v FROM "kumiko_events" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2::uuid[]) GROUP BY "aggregate_id"`, [tenantId, [...aggregateIds]]);
176
+ const rows = await unsafeReadRetrying(db, `SELECT "aggregate_id" AS id, MAX("version") AS v FROM "kumiko_events" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2::uuid[]) GROUP BY "aggregate_id"`, [tenantId, toPostgresArrayLiteral(aggregateIds)]);
179
177
  for (const row of rows) {
180
178
  if (row.v !== null)
181
179
  versions.set(row.id, row.v);
@@ -260,8 +258,8 @@ export async function selectPendingAndNewEventRows(db, cursor, pendingRanges, ba
260
258
  (SELECT * FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT $4)
261
259
  ORDER BY "id" ASC LIMIT $4`, [
262
260
  cursor,
263
- pendingRanges.map((r) => r.from.toString()),
264
- pendingRanges.map((r) => r.to.toString()),
261
+ toPostgresArrayLiteral(pendingRanges.map((r) => r.from)),
262
+ toPostgresArrayLiteral(pendingRanges.map((r) => r.to)),
265
263
  batchSize,
266
264
  ]);
267
265
  }
@@ -274,7 +272,10 @@ export async function selectSmallestVisibleIdInRanges(db, pendingRanges) {
274
272
  const rows = await unsafeReadRetrying(db, `SELECT MIN("id")::text AS id FROM "kumiko_events"
275
273
  WHERE EXISTS (
276
274
  SELECT 1 FROM unnest($1::bigint[], $2::bigint[]) AS g(f, t) WHERE "id" BETWEEN g.f AND g.t
277
- )`, [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())]);
275
+ )`, [
276
+ toPostgresArrayLiteral(pendingRanges.map((r) => r.from)),
277
+ toPostgresArrayLiteral(pendingRanges.map((r) => r.to)),
278
+ ]);
278
279
  const id = rows[0]?.id;
279
280
  return id === null || id === undefined ? null : BigInt(id);
280
281
  }
@@ -1,4 +1,5 @@
1
1
  import { asRawClient } from "../query.js";
2
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
2
3
  export async function readPiiBackfillState(db, fingerprint) {
3
4
  const rows = await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
4
5
  "failed_event_ids"::text[] AS "failedEventIds"
@@ -19,7 +20,7 @@ export async function persistPiiBackfillState(db, fingerprint, update) {
19
20
  fingerprint,
20
21
  update.cursor.toString(),
21
22
  update.markCompleted,
22
- [...update.addFailedEventIds],
23
- [...update.removeFailedEventIds],
23
+ toPostgresArrayLiteral(update.addFailedEventIds),
24
+ toPostgresArrayLiteral(update.removeFailedEventIds),
24
25
  ]);
25
26
  }
@@ -0,0 +1,2 @@
1
+ export type PostgresArrayElement = string | number | bigint | null;
2
+ export declare function toPostgresArrayLiteral(values: readonly PostgresArrayElement[]): string;
@@ -0,0 +1,6 @@
1
+ // Bun.SQL stringifies JS arrays via toString, so array params travel as a
2
+ // Postgres array-literal string (`$1::text[]`) — identical on postgres-js and Bun.SQL.
3
+ export function toPostgresArrayLiteral(values) {
4
+ const elements = values.map((value) => value === null ? "NULL" : `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
5
+ return `{${elements.join(",")}}`;
6
+ }
@@ -1,4 +1,5 @@
1
1
  import { asRawClient, unsafeReadRetrying } from "../query.js";
2
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
2
3
  export async function markProjectionRebuilding(db, projectionName) {
3
4
  await asRawClient(db).unsafe(`INSERT INTO "kumiko_projections" ("name", "status") VALUES ($1, 'rebuilding')
4
5
  ON CONFLICT ("name") DO UPDATE SET
@@ -23,7 +24,7 @@ export async function selectEventsForProjectionRebuildBatch(db, aggregateTypes,
23
24
  WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
24
25
  )
25
26
  ORDER BY e."id" ASC
26
- LIMIT $4`, [aggregateTypes, eventTypes, afterId, limit]);
27
+ LIMIT $4`, [toPostgresArrayLiteral(aggregateTypes), toPostgresArrayLiteral(eventTypes), afterId, limit]);
27
28
  }
28
29
  // Total subscribed events in the log — same source/type filter as
29
30
  // selectEventsForProjectionRebuildBatch, no id cursor. Under the cutover fence
@@ -39,7 +40,7 @@ export async function countSubscribedEvents(db, aggregateTypes, eventTypes) {
39
40
  AND NOT EXISTS (
40
41
  SELECT 1 FROM "kumiko_archived_streams" a
41
42
  WHERE a."tenant_id" = e."tenant_id" AND a."aggregate_id" = e."aggregate_id"
42
- )`, [aggregateTypes, eventTypes]);
43
+ )`, [toPostgresArrayLiteral(aggregateTypes), toPostgresArrayLiteral(eventTypes)]);
43
44
  const raw = rows[0]?.n;
44
45
  if (typeof raw === "bigint")
45
46
  return raw;
@@ -9,7 +9,7 @@ export declare function assertLiveColumnsMatchMeta(db: DbConnection | DbTx, meta
9
9
  export declare function assertLiveTableHasNoRowLevelSecurity(tx: AnyDb, tableName: string, projectionName?: string): Promise<void>;
10
10
  export declare function buildShadowTable(tx: AnyDb, meta: EntityTableMeta): Promise<void>;
11
11
  export declare function fenceLiveTable(tx: AnyDb, tableName: string, lockTimeoutMs: number): Promise<void>;
12
- export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[], derivedRowIds?: Readonly<Record<string, ProjectionRowIdOf>>): Promise<void>;
12
+ export declare function assertNoUnreachableLiveRows(tx: AnyDb, projectionName: string, tableName: string, aggregateTypes: readonly string[], derivedRowIds?: Readonly<Record<string, ProjectionRowIdOf>>, derivedRowIdBatchSize?: number): Promise<void>;
13
13
  export declare function assertNoBlindIndexLoss(tx: AnyDb, tableName: string, meta: EntityTableMeta, projectionName: string): Promise<void>;
14
14
  export type ColumnDriftResult = {
15
15
  readonly rowCount: number;
@@ -22,6 +22,7 @@ import { configuredBlindIndexKey } from "../../crypto/index.js";
22
22
  import { asEntityTableMeta, asRawClient } from "../query.js";
23
23
  import { renderTableDdl } from "../render-ddl.js";
24
24
  import { columnNamesOf, tableExists } from "../schema-inspection.js";
25
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
25
26
  import { quoteTableIdent } from "./table-ops.js";
26
27
  export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
27
28
  const SCHEMA_IDENT = quoteTableIdent(PROJECTION_REBUILD_SCHEMA);
@@ -187,15 +188,18 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
187
188
  // strict — a row keyed by the raw aggregate id of a derived stream is a ghost.
188
189
  const DERIVED_ROW_ID_BATCH_SIZE = 5000;
189
190
  const DERIVED_ROW_ID_TABLE = "pg_temp.kumiko_derived_row_ids";
190
- async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf) {
191
+ async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf, batchSize) {
191
192
  let cursor;
192
193
  for (;;) {
193
- const page = await raw.unsafe(`SELECT DISTINCT "tenant_id"::text AS tenant_id, "aggregate_id"::text AS aggregate_id
194
+ const page = await raw.unsafe(
195
+ // Selecting the uuid columns (no text alias) keeps ORDER BY and the keyset
196
+ // comparison on the same type; text ordering is collation-dependent.
197
+ `SELECT DISTINCT "tenant_id", "aggregate_id"
194
198
  FROM "kumiko_events"
195
199
  WHERE "aggregate_type" = $1
196
200
  ${cursor ? `AND ("tenant_id", "aggregate_id") > ($2::uuid, $3::uuid)` : ""}
197
201
  ORDER BY "tenant_id", "aggregate_id"
198
- LIMIT ${DERIVED_ROW_ID_BATCH_SIZE}`, cursor ? [aggregateType, cursor.tenant_id, cursor.aggregate_id] : [aggregateType]);
202
+ LIMIT ${batchSize}`, cursor ? [aggregateType, cursor.tenant_id, cursor.aggregate_id] : [aggregateType]);
199
203
  // skip: keyset exhausted — every source event of this type has been mapped
200
204
  if (page.length === 0)
201
205
  return;
@@ -212,14 +216,14 @@ async function fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf
212
216
  }
213
217
  return derived;
214
218
  });
215
- await raw.unsafe(`INSERT INTO ${DERIVED_ROW_ID_TABLE} ("id") SELECT unnest($1::uuid[]) ON CONFLICT DO NOTHING`, [derivedIds]);
219
+ await raw.unsafe(`INSERT INTO ${DERIVED_ROW_ID_TABLE} ("id") SELECT unnest($1::uuid[]) ON CONFLICT DO NOTHING`, [toPostgresArrayLiteral(derivedIds)]);
216
220
  // skip: a short page is the last one
217
- if (page.length < DERIVED_ROW_ID_BATCH_SIZE)
221
+ if (page.length < batchSize)
218
222
  return;
219
223
  cursor = page[page.length - 1];
220
224
  }
221
225
  }
222
- export async function assertNoUnreachableLiveRows(tx, projectionName, tableName, aggregateTypes, derivedRowIds = {}) {
226
+ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName, aggregateTypes, derivedRowIds = {}, derivedRowIdBatchSize = DERIVED_ROW_ID_BATCH_SIZE) {
223
227
  // skip: no source streams → no events could back any row anyway; a rebuild
224
228
  // of a subscription-less projection swaps an empty shadow (handled upstream).
225
229
  if (aggregateTypes.length === 0)
@@ -237,7 +241,7 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
237
241
  // skip: filtered by derivedTypes above, narrows the Record lookup for the compiler
238
242
  if (rowIdOf === undefined)
239
243
  continue;
240
- await fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf);
244
+ await fillDerivedRowIdTable(raw, projectionName, aggregateType, rowIdOf, derivedRowIdBatchSize);
241
245
  }
242
246
  }
243
247
  const unbackedClauses = [];
@@ -252,7 +256,7 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
252
256
  }
253
257
  const ghosts = await raw.unsafe(`SELECT l."id" FROM public.${t} l
254
258
  WHERE ${unbackedClauses.join(" AND ")}
255
- LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, directTypes.length > 0 ? [directTypes] : []);
259
+ LIMIT ${UNREACHABLE_SAMPLE_LIMIT}`, directTypes.length > 0 ? [toPostgresArrayLiteral(directTypes)] : []);
256
260
  // skip: every live row has a backing event — nothing unreachable, swap is safe
257
261
  if (ghosts.length === 0)
258
262
  return;
@@ -1 +1 @@
1
- export { type AnyDb, aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type JsonTextMatch, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
1
+ export { type AnyDb, aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOne, type JsonTextMatch, type OrderByClause, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, type SelectOptions, selectMany, type TableInfo, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, type WhereObject, type WhereOperator, type WhereValue, } from "../bun-db/query.js";
package/dist/db/query.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Provider-neutrale Query-API. Re-exported aus bun-db/query.
2
2
  // Alle Consumer importieren von hier, nicht direkt aus bun-db/.
3
- export { aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "../bun-db/query.js";
3
+ export { aggregateWhere, asEntityTableMeta, asRawClient, coerceRow, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOne, runInNewTransaction, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "../bun-db/query.js";
@@ -14,10 +14,23 @@ export const retiredFrameworkTables = [
14
14
  explanation: "replaced by store_job_runs; the job run history was not migrated (operational data, not a system of record)",
15
15
  },
16
16
  ];
17
- const IDENT = `"?([^"\\s(;,.]+)"?`;
17
+ // Optional schema prefix is skipped; only the table name is captured.
18
+ const IDENT = `(?:"?[^"\\s(;,.]+"?\\.)?"?([^"\\s(;,.]+)"?`;
18
19
  const CREATE_TABLE_RE = new RegExp(`^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?${IDENT}`, "i");
19
- const DROP_TABLE_RE = new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i");
20
+ // DROP TABLE accepts a comma list; the list is split and each entry parsed with IDENT.
21
+ const DROP_TABLE_LIST_RE = /^DROP TABLE\s+(?:IF EXISTS\s+)?([^;]+)/i;
22
+ const IDENT_ONLY_RE = new RegExp(`^\\s*${IDENT}`);
23
+ const RENAME_TABLE_RE = new RegExp(`^ALTER TABLE\\s+(?:IF EXISTS\\s+)?(?:ONLY\\s+)?${IDENT}\\s+RENAME TO\\s+${IDENT}`, "i");
20
24
  const COMMENTED_DROP_TABLE_RE = new RegExp(`^--\\s*DESTRUCTIVE:\\s*DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i");
25
+ function droppedTableNames(line) {
26
+ const list = DROP_TABLE_LIST_RE.exec(line)?.[1];
27
+ if (list === undefined)
28
+ return [];
29
+ return list
30
+ .split(",")
31
+ .map((entry) => IDENT_ONLY_RE.exec(entry)?.[1])
32
+ .filter((name) => name !== undefined);
33
+ }
21
34
  // Last known state per table across all migrations, in order — so a
22
35
  // DROP followed by a re-CREATE (managed-projection recreate) counts as
23
36
  // "created", and a commented drop followed by an executable one as "dropped".
@@ -38,8 +51,13 @@ function scanTableLifecycle(sqlTexts) {
38
51
  states.set(created, "created");
39
52
  continue;
40
53
  }
41
- const dropped = DROP_TABLE_RE.exec(line)?.[1];
42
- if (dropped !== undefined)
54
+ const renamed = RENAME_TABLE_RE.exec(line);
55
+ if (renamed?.[1] !== undefined && renamed[2] !== undefined) {
56
+ states.set(renamed[1], "dropped");
57
+ states.set(renamed[2], "created");
58
+ continue;
59
+ }
60
+ for (const dropped of droppedTableNames(line))
43
61
  states.set(dropped, "dropped");
44
62
  }
45
63
  }
@@ -378,8 +378,12 @@ export function buildEntityTable(entityName, entity, options) {
378
378
  // entity.indexes[].where is now a SqlExpression (was drizzle SQL).
379
379
  // Pass through to the IndexBuilderWithCols.where()-API.
380
380
  if (def.where !== undefined) {
381
- if (isSqlExpression(def.where))
382
- chain = chain.where(def.where);
381
+ // A silently dropped where would build a full (unique) index that
382
+ // entity-table-meta flags as needsManualWhere.
383
+ if (!isSqlExpression(def.where)) {
384
+ throw new Error(`[${tableName}] index "${indexName}": where must be a sql\`...\` expression`);
385
+ }
386
+ chain = chain.where(def.where);
383
387
  }
384
388
  else if (def.unique === true && entity.softDelete === true) {
385
389
  // Soft-deleted rows keep their value, so a full unique index would
@@ -19,6 +19,7 @@ export declare function createSystemDbView(db: TenantDb, dbOutsideTransaction?:
19
19
  export declare function scopeTenantDbToTenant(db: TenantDb, tenantId: TenantId): TenantDb;
20
20
  export declare function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[];
21
21
  export { tableNameOf };
22
+ export declare function requestedTenantIds(requested: unknown): readonly unknown[] | undefined;
22
23
  export declare function hasTenantColumn(table: Table | EntityTableMeta): boolean;
23
24
  export type TenantDbGrants = {
24
25
  readonly globalWrites?: EscapeHatchDeclaration;
@@ -37,4 +38,5 @@ export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, rea
37
38
  export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
38
39
  export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
39
40
  export declare function createTenantDb(db: DbRunner, tenantId: TenantId, mode?: TenantDbMode, tracer?: Tracer, meter?: Meter, signal?: AbortSignal, grants?: TenantDbGrants): TenantDb;
41
+ export declare function createProjectingTenantDb(db: DbRunner, tenantId: TenantId, projectionRegistry: Registry | undefined): TenantDb;
40
42
  export { asRawClient };
@@ -1,4 +1,5 @@
1
1
  import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
3
4
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
4
5
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
@@ -235,6 +236,19 @@ export { tableNameOf };
235
236
  function hasGrant(decl) {
236
237
  return decl !== undefined && decl.reason.trim().length > 0;
237
238
  }
239
+ // Tenant ids a where.tenantId filter asks for; undefined for operator forms
240
+ // that cannot narrow to a tenant list (ne, like, ...).
241
+ export function requestedTenantIds(requested) {
242
+ if (Array.isArray(requested))
243
+ return requested;
244
+ if (!isPlainObject(requested))
245
+ return [requested];
246
+ if (Array.isArray(requested["in"]))
247
+ return requested["in"];
248
+ if ("eq" in requested)
249
+ return [requested["eq"]];
250
+ return undefined;
251
+ }
238
252
  function isForeignTenantId(tenantIdValue) {
239
253
  return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
240
254
  }
@@ -360,7 +374,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
360
374
  const allowed = [tenantId, SYSTEM_TENANT_ID];
361
375
  const requested = where?.["tenantId"];
362
376
  if (requested !== undefined) {
363
- const requestedList = Array.isArray(requested) ? requested : [requested];
377
+ const requestedList = requestedTenantIds(requested) ?? [];
364
378
  const narrowed = requestedList.filter((t) => typeof t === "string" && allowed.includes(t));
365
379
  return { ...where, tenantId: narrowed.length > 0 ? narrowed : allowed };
366
380
  }
@@ -605,4 +619,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
605
619
  bindProjectionRegistry(tenantDb, projectionRegistry);
606
620
  return tenantDb;
607
621
  }
622
+ // Tenant-mode TenantDb whose writes reach the registry's custom projections. The positional
623
+ // undefineds of createTenantDb would hide a moved grants parameter from the type checker.
624
+ export function createProjectingTenantDb(db, tenantId, projectionRegistry) {
625
+ return createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionRegistry ? { projectionRegistry } : undefined);
626
+ }
608
627
  export { asRawClient };
@@ -1,6 +1,10 @@
1
+ import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
1
2
  export type StoredFileStore = {
2
3
  list(prefix: string): Promise<readonly string[]>;
3
4
  delete(key: string): Promise<void>;
4
5
  };
5
6
  export declare function listDerivativeKeys(originalKey: string, store: Pick<StoredFileStore, "list">, errorContext: string): Promise<readonly string[]>;
6
7
  export declare function deleteStoredFileAndDerivatives(originalKey: string, store: StoredFileStore, errorContext: string): Promise<readonly string[]>;
8
+ export declare function deleteFileRefBytes(files: FileContext, row: {
9
+ readonly storageKey: string;
10
+ }, errorContext: string): Promise<readonly string[]>;
@@ -68,3 +68,12 @@ export async function deleteStoredFileAndDerivatives(originalKey, store, errorCo
68
68
  }
69
69
  return failedKeys;
70
70
  }
71
+ // deleteStoredFileAndDerivatives through ctx.files for a key read from a tenant-filtered fileRef
72
+ // row. After a tenant handover the key keeps the previous tenant's prefix, which ref() and list()
73
+ // reject; every key the helper deletes is the original or one of its derivatives.
74
+ export function deleteFileRefBytes(files, row, errorContext) {
75
+ return deleteStoredFileAndDerivatives(row.storageKey, {
76
+ list: () => files.listFromFileRef(row),
77
+ delete: (key) => files.refFromFileRef({ storageKey: key }).delete(),
78
+ }, errorContext);
79
+ }
@@ -1,5 +1,5 @@
1
1
  export type { StoredFileStore } from "./delete-stored-file.js";
2
- export { deleteStoredFileAndDerivatives, listDerivativeKeys } from "./delete-stored-file.js";
2
+ export { deleteFileRefBytes, deleteStoredFileAndDerivatives, listDerivativeKeys, } from "./delete-stored-file.js";
3
3
  export type { DerivativesContextDeps } from "./derivatives-context.js";
4
4
  export { createDerivativesContext, resolveRenderer } from "./derivatives-context.js";
5
5
  export { resolveFieldVariant } from "./field-variants.js";
@@ -1,4 +1,4 @@
1
- export { deleteStoredFileAndDerivatives, listDerivativeKeys } from "./delete-stored-file.js";
1
+ export { deleteFileRefBytes, deleteStoredFileAndDerivatives, listDerivativeKeys, } from "./delete-stored-file.js";
2
2
  export { createDerivativesContext, resolveRenderer } from "./derivatives-context.js";
3
3
  export { resolveFieldVariant } from "./field-variants.js";
4
4
  export { canonicalJson, derivativeListPrefix, isDerivativeKeyOf, parseDerivativeKey, specHash, VARIANT_NAME_PATTERN, variantSuffix, } from "./variant-key.js";
@@ -929,6 +929,9 @@ export function validateLockedWhen(feature) {
929
929
  if (isCiphertextAtRest(field)) {
930
930
  throw new Error(`${where} is personal or encrypted — the lock condition is evaluated on the stored value, which is ciphertext. Use a plaintext field.`);
931
931
  }
932
+ if (field.access?.read !== undefined) {
933
+ throw new Error(`${where} has a read restriction — roles that cannot read it get records without it, and the client would show every record as locked. Drop field access.read or use another field.`);
934
+ }
932
935
  if (!LOCKABLE_FIELD_TYPES.has(field.type)) {
933
936
  throw new Error(`${where} has type "${field.type}", which does not store a plain scalar the condition can compare. Use a text, select, boolean, number or reference field.`);
934
937
  }