@cosmicdrift/kumiko-framework 0.363.0 → 0.365.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +319 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +215 -0
- package/agents/examples/ledger-invoicing.md +236 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +250 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +418 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +75 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -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`, [
|
|
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`, [
|
|
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
|
-
|
|
477
|
+
const fieldDef = entity.fields[field];
|
|
478
|
+
if (fieldDef === undefined || !isPlainUserOwnedField(fieldDef))
|
|
470
479
|
return { kind: "unchanged" };
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
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
|
-
//
|
|
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
|
|
527
|
+
function ciphertextSubjectOrNull(ciphertext) {
|
|
500
528
|
try {
|
|
501
|
-
|
|
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
|
|
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) {
|
|
@@ -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
|
|
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
|
|
30
|
-
export declare function advanceConsumerPastEventReturning
|
|
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
|
|
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
|
|
36
|
-
export declare function removePendingGapReturning
|
|
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>;
|
|
@@ -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`, [
|
|
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)`, [
|
|
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
|
}
|
|
@@ -56,5 +56,5 @@ export type PendingIdRange = {
|
|
|
56
56
|
readonly from: bigint;
|
|
57
57
|
readonly to: bigint;
|
|
58
58
|
};
|
|
59
|
-
export declare function selectPendingAndNewEventRows
|
|
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,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.
|
|
16
|
-
// only a runner without savepoint() counts as a pool.
|
|
16
|
+
// that unit.
|
|
17
17
|
export async function runAtomicEventWrite(db, write) {
|
|
18
|
-
|
|
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,
|
|
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
|
|
264
|
-
pendingRanges.map((r) => r.to
|
|
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
|
-
)`, [
|
|
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
|
-
|
|
23
|
-
|
|
23
|
+
toPostgresArrayLiteral(update.addFailedEventIds),
|
|
24
|
+
toPostgresArrayLiteral(update.removeFailedEventIds),
|
|
24
25
|
]);
|
|
25
26
|
}
|
|
@@ -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,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
|
|
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,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
|
|
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(
|
|
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 ${
|
|
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 <
|
|
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;
|
package/dist/db/query.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
if (
|
|
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
|
}
|
package/dist/db/table-builder.js
CHANGED
|
@@ -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
|
-
|
|
382
|
-
|
|
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
|
|
@@ -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;
|
|
@@ -15,3 +15,20 @@ export function tenantDbRunner(tenantDb) {
|
|
|
15
15
|
}
|
|
16
16
|
return runner;
|
|
17
17
|
}
|
|
18
|
+
const writesDeniedReasons = new WeakMap();
|
|
19
|
+
export function denyTenantDbWrites(tenantDb, reason) {
|
|
20
|
+
writesDeniedReasons.set(tenantDb, reason);
|
|
21
|
+
}
|
|
22
|
+
export function tenantDbWritesDeniedError(operation, reason) {
|
|
23
|
+
return new InternalError({
|
|
24
|
+
message: `${operation}: writes are not available ${reason}; run the write through ctx.write so it gets its own transaction`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// For framework code that writes through the raw runner (executor verbs, event appends, projection steps):
|
|
28
|
+
// the TenantDb's own write methods are denied via TenantDbGrants, this closes the runner-based door.
|
|
29
|
+
export function tenantDbWriteRunner(tenantDb, operation) {
|
|
30
|
+
const reason = writesDeniedReasons.get(tenantDb);
|
|
31
|
+
if (reason !== undefined)
|
|
32
|
+
throw tenantDbWritesDeniedError(operation, reason);
|
|
33
|
+
return tenantDbRunner(tenantDb);
|
|
34
|
+
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -16,8 +16,10 @@ export declare function unsafeRawForDeclaredStep(holder: TenantDb | UncheckedSys
|
|
|
16
16
|
export declare function withSystemDbUnsafeRawGrant(systemDb: UncheckedSystemDb, grant: EscapeHatchDeclaration | undefined, deniedCallerLabel: string): UncheckedSystemDb;
|
|
17
17
|
export declare function createUncheckedSystemDb(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
|
|
18
18
|
export declare function createSystemDbView(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
|
|
19
|
+
export declare function scopeTenantDbToTenant(db: TenantDb, tenantId: TenantId): TenantDb;
|
|
19
20
|
export declare function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[];
|
|
20
21
|
export { tableNameOf };
|
|
22
|
+
export declare function requestedTenantIds(requested: unknown): readonly unknown[] | undefined;
|
|
21
23
|
export declare function hasTenantColumn(table: Table | EntityTableMeta): boolean;
|
|
22
24
|
export type TenantDbGrants = {
|
|
23
25
|
readonly globalWrites?: EscapeHatchDeclaration;
|
|
@@ -26,6 +28,7 @@ export type TenantDbGrants = {
|
|
|
26
28
|
readonly memberReadOnly?: boolean;
|
|
27
29
|
readonly personalDataGate?: PersonalDataGate;
|
|
28
30
|
readonly projectionRegistry?: Registry;
|
|
31
|
+
readonly writesDeniedReason?: string;
|
|
29
32
|
};
|
|
30
33
|
export type PersonalDataGate = (tableName: string, keys: readonly string[], entity?: EntityDefinition) => void;
|
|
31
34
|
export declare function withUnsafeRawGrant(tenantDb: TenantDb, grant: EscapeHatchDeclaration | undefined): TenantDb;
|
|
@@ -35,4 +38,5 @@ export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, rea
|
|
|
35
38
|
export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
|
|
36
39
|
export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
|
|
37
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;
|
|
38
42
|
export { asRawClient };
|