@cosmicdrift/kumiko-framework 0.364.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 +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +3 -3
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +21 -4
- 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 +7 -0
- package/dist/bun-db/query.js +50 -18
- 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/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +70 -47
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- 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.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.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.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/entrypoint/index.d.ts +2 -2
- 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/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -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/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- 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.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- 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/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +116 -33
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- 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/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- 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/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- 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 +4 -4
- package/src/changes.json +305 -0
|
@@ -2,14 +2,15 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
|
|
|
2
2
|
import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
3
3
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
4
4
|
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
5
|
-
import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
|
|
6
|
-
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
5
|
+
import { findUnavailableSelectOptions, isRecordLocked, isUnchangedValue, } from "../engine/screen-helpers.js";
|
|
6
|
+
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, isSubjectErasedConflict, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
7
7
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
8
8
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
9
9
|
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
10
10
|
import { generateId } from "../utils/index.js";
|
|
11
11
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
12
12
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
13
|
+
import { WritePurposes, } from "./event-store-executor.js";
|
|
13
14
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
14
15
|
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
15
16
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
@@ -34,19 +35,6 @@ async function projectEntityEvent(db, runner, event) {
|
|
|
34
35
|
// behavior-preserving relocation, not a redesign: every closure below is
|
|
35
36
|
// unchanged from the original, just relocated behind an explicit
|
|
36
37
|
// ExecutorContext instead of capturing the factory's local scope directly.
|
|
37
|
-
// updateOptions.skipUnchanged (#464) — opt-in: a resubmitted-but-identical
|
|
38
|
-
// key is dropped before encryption so pii/encrypted fields don't get a fresh
|
|
39
|
-
// AEAD ciphertext (new nonce) for no real change, and the event's `changes`
|
|
40
|
-
// don't carry a phantom diff. Opt-in, not the executor's default, because
|
|
41
|
-
// direct executor.update() callers rely on the current behavior to FORCE a
|
|
42
|
-
// re-encrypt of an unchanged plaintext — KEK-rotation (auth-mfa reencrypt.job)
|
|
43
|
-
// and the user-data-rights #494 backfill both resubmit the current value on
|
|
44
|
-
// purpose to land a fresh event/ciphertext. Only the generic entity update
|
|
45
|
-
// handler (entity-handlers.ts) sets this flag; those two call their own
|
|
46
|
-
// executor directly and never see it.
|
|
47
|
-
function isUnchangedValue(a, b) {
|
|
48
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
49
|
-
}
|
|
50
38
|
function selectOptionNotAvailableFailure(entries) {
|
|
51
39
|
const [first] = entries;
|
|
52
40
|
if (!first)
|
|
@@ -125,6 +113,11 @@ export function createWriteVerbs(ctx) {
|
|
|
125
113
|
}
|
|
126
114
|
return writeFailure(new RecordLockedError({ entityName, entityId: id }));
|
|
127
115
|
}
|
|
116
|
+
// Erasure writes run after the subject key is gone; they store the erased
|
|
117
|
+
// sentinel for that subject's fields instead of failing on re-encrypt.
|
|
118
|
+
function erasureSentinelOption(options) {
|
|
119
|
+
return options?.writePurpose === WritePurposes.erasure ? { onErasedSubject: "sentinel" } : {};
|
|
120
|
+
}
|
|
128
121
|
const eventVersion = entity.eventVersion ?? 1;
|
|
129
122
|
let insertSchema;
|
|
130
123
|
let updateSchema;
|
|
@@ -149,6 +142,45 @@ export function createWriteVerbs(ctx) {
|
|
|
149
142
|
}
|
|
150
143
|
return { row: row };
|
|
151
144
|
}
|
|
145
|
+
// A forgotten subject has no key any more: restore re-encrypts the stored row and reports
|
|
146
|
+
// that as a failure result like any other write conflict instead of throwing.
|
|
147
|
+
async function appendRestoredEvent(runner, user, id, previousPlain, streamTenantId, currentVersion) {
|
|
148
|
+
try {
|
|
149
|
+
const previous = await encryptForStorage(previousPlain, user, {
|
|
150
|
+
keyTenantId: streamTenantId,
|
|
151
|
+
});
|
|
152
|
+
const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
153
|
+
aggregateId: String(id),
|
|
154
|
+
aggregateType: entityName,
|
|
155
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
156
|
+
expectedVersion: currentVersion,
|
|
157
|
+
type: entityEventName(entityName, "restored"),
|
|
158
|
+
eventVersion,
|
|
159
|
+
payload: { previous },
|
|
160
|
+
metadata: buildEventMetadata(user),
|
|
161
|
+
}));
|
|
162
|
+
return { event };
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
if (isSubjectErasedConflict(e))
|
|
166
|
+
return { failure: writeFailure(e) };
|
|
167
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
168
|
+
return {
|
|
169
|
+
failure: writeFailure(new FrameworkVersionConflict({
|
|
170
|
+
entityId: id,
|
|
171
|
+
expectedVersion: currentVersion,
|
|
172
|
+
currentVersion: -1,
|
|
173
|
+
})),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
177
|
+
return {
|
|
178
|
+
failure: writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey })),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
152
184
|
async function expectedStreamVersion(db, id, streamTenant, expect) {
|
|
153
185
|
const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
|
|
154
186
|
const row = snapshot.row;
|
|
@@ -415,7 +447,12 @@ export function createWriteVerbs(ctx) {
|
|
|
415
447
|
const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
|
|
416
448
|
if (lockedUpdate)
|
|
417
449
|
return lockedUpdate;
|
|
418
|
-
|
|
450
|
+
// Statutory retention/erasure writes may change a condition field (e.g. null a PII
|
|
451
|
+
// reference) and must not be blocked by the select rule — same exemption as the lock.
|
|
452
|
+
const isStatutorySystemWrite = updateOptions?.writePurpose !== undefined && isFrameworkSystemUser(user);
|
|
453
|
+
const unavailableOnUpdate = isStatutorySystemWrite
|
|
454
|
+
? undefined
|
|
455
|
+
: selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
|
|
419
456
|
if (unavailableOnUpdate)
|
|
420
457
|
return unavailableOnUpdate;
|
|
421
458
|
const streamTenant = streamTenantFor(user, streamTenantId);
|
|
@@ -454,6 +491,9 @@ export function createWriteVerbs(ctx) {
|
|
|
454
491
|
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
455
492
|
// subjectSource: partial changes may carry a pii field without its
|
|
456
493
|
// ownerField — the merged row still names the subject.
|
|
494
|
+
// skipUnchanged (#464) is opt-in: an identical resubmitted key is dropped before
|
|
495
|
+
// encryption so pii/encrypted fields get no fresh ciphertext for no real change.
|
|
496
|
+
// Direct callers (KEK rotation, #494 backfill) rely on the default to force a re-encrypt.
|
|
457
497
|
const submittedChanges = updateOptions?.skipUnchanged
|
|
458
498
|
? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
|
|
459
499
|
: changes;
|
|
@@ -469,6 +509,7 @@ export function createWriteVerbs(ctx) {
|
|
|
469
509
|
onlyKeys: Object.keys(changesToPersist),
|
|
470
510
|
subjectSource: mergedNew,
|
|
471
511
|
keyTenantId: streamTenantId,
|
|
512
|
+
...erasureSentinelOption(updateOptions),
|
|
472
513
|
});
|
|
473
514
|
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
474
515
|
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
@@ -484,6 +525,7 @@ export function createWriteVerbs(ctx) {
|
|
|
484
525
|
const encryptedPrevious = await encryptForStorage(previous, user, {
|
|
485
526
|
keyTenantId: streamTenantId,
|
|
486
527
|
ownerFallback: submittedChanges,
|
|
528
|
+
...erasureSentinelOption(updateOptions),
|
|
487
529
|
});
|
|
488
530
|
// Savepoint-scoped — see the create() append() above for why:
|
|
489
531
|
// confines a losing writer's unique-violation to a nested scope
|
|
@@ -620,7 +662,10 @@ export function createWriteVerbs(ctx) {
|
|
|
620
662
|
type: entityEventName(entityName, "deleted"),
|
|
621
663
|
eventVersion,
|
|
622
664
|
payload: {
|
|
623
|
-
previous: await encryptForStorage(existing, user, {
|
|
665
|
+
previous: await encryptForStorage(existing, user, {
|
|
666
|
+
keyTenantId: streamTenantId,
|
|
667
|
+
...erasureSentinelOption(options),
|
|
668
|
+
}),
|
|
624
669
|
},
|
|
625
670
|
metadata: buildEventMetadata(user),
|
|
626
671
|
}));
|
|
@@ -702,7 +747,10 @@ export function createWriteVerbs(ctx) {
|
|
|
702
747
|
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
703
748
|
// plaintext must not land in the immutable log, least of all on forget.
|
|
704
749
|
payload: {
|
|
705
|
-
previous: await encryptForStorage(existing, user, {
|
|
750
|
+
previous: await encryptForStorage(existing, user, {
|
|
751
|
+
keyTenantId: streamTenantId,
|
|
752
|
+
onErasedSubject: "sentinel",
|
|
753
|
+
}),
|
|
706
754
|
},
|
|
707
755
|
metadata: buildEventMetadata(user),
|
|
708
756
|
}));
|
|
@@ -778,35 +826,10 @@ export function createWriteVerbs(ctx) {
|
|
|
778
826
|
// pii, and envelope encryption is not idempotent: decrypt, then
|
|
779
827
|
// re-encrypt as delete does.
|
|
780
828
|
const previousPlain = await decryptForRead(data);
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
try {
|
|
786
|
-
event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
787
|
-
aggregateId: String(payload.id),
|
|
788
|
-
aggregateType: entityName,
|
|
789
|
-
tenantId: streamTenantFor(user, streamTenantId),
|
|
790
|
-
expectedVersion: currentVersion,
|
|
791
|
-
type: entityEventName(entityName, "restored"),
|
|
792
|
-
eventVersion,
|
|
793
|
-
payload: { previous: previousForStorage },
|
|
794
|
-
metadata: buildEventMetadata(user),
|
|
795
|
-
}));
|
|
796
|
-
}
|
|
797
|
-
catch (e) {
|
|
798
|
-
if (e instanceof EventStoreVersionConflict) {
|
|
799
|
-
return writeFailure(new FrameworkVersionConflict({
|
|
800
|
-
entityId: payload.id,
|
|
801
|
-
expectedVersion: currentVersion,
|
|
802
|
-
currentVersion: -1,
|
|
803
|
-
}));
|
|
804
|
-
}
|
|
805
|
-
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
806
|
-
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
807
|
-
}
|
|
808
|
-
throw e;
|
|
809
|
-
}
|
|
829
|
+
const appended = await appendRestoredEvent(runner, user, payload.id, previousPlain, streamTenantId, currentVersion);
|
|
830
|
+
if ("failure" in appended)
|
|
831
|
+
return appended.failure;
|
|
832
|
+
const event = appended.event;
|
|
810
833
|
// Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
|
|
811
834
|
// plus the version bump, so there is no sensitive-field drift and no
|
|
812
835
|
// payload override is needed.
|
package/dist/db/index.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineM
|
|
|
24
24
|
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
25
25
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
|
|
26
26
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
27
|
+
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
27
28
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
28
29
|
export type { SelectOptions, WhereObject, WhereValue } from "./query-api.js";
|
|
29
30
|
export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
|
package/dist/db/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, Migr
|
|
|
18
18
|
export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
19
19
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
|
|
20
20
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
21
|
+
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
21
22
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
22
23
|
export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
|
|
23
24
|
export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
|
|
@@ -12,6 +12,7 @@ export type ApplyResult = {
|
|
|
12
12
|
readonly applied: readonly string[];
|
|
13
13
|
readonly skipped: readonly string[];
|
|
14
14
|
};
|
|
15
|
+
export declare const MIGRATION_ADVISORY_LOCK_KEY = 1802857833;
|
|
15
16
|
export declare function splitSqlStatements(sqlText: string): readonly string[];
|
|
16
17
|
export declare function loadMigrationsFromDir(dir: string, preprocess?: (sql: string) => string): readonly Migration[];
|
|
17
18
|
export declare function readMigrationSqlTexts(dir: string): readonly string[];
|
|
@@ -17,11 +17,11 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
20
|
-
import { asRawClient, hasBegin } from "../bun-db/query.js";
|
|
20
|
+
import { asRawClient, hasBegin, } from "../bun-db/query.js";
|
|
21
21
|
// Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
|
|
22
22
|
// gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
|
|
23
23
|
// blockiert bis erster fertig).
|
|
24
|
-
const
|
|
24
|
+
export const MIGRATION_ADVISORY_LOCK_KEY = 0x6b756d69; // "kumi" in ASCII
|
|
25
25
|
const MIGRATIONS_TABLE_DDL = `
|
|
26
26
|
CREATE TABLE IF NOT EXISTS "_kumiko_migrations" (
|
|
27
27
|
"id" text PRIMARY KEY NOT NULL,
|
|
@@ -186,8 +186,18 @@ function migrationClient(db) {
|
|
|
186
186
|
async function executeRaw(db, sqlText) {
|
|
187
187
|
await migrationClient(db).unsafe(sqlText);
|
|
188
188
|
}
|
|
189
|
+
async function reserveConnection(db) {
|
|
190
|
+
const client = migrationClient(db);
|
|
191
|
+
if (typeof client.reserve !== "function") {
|
|
192
|
+
throw new Error("migrate-runner: db argument has no .reserve() (need Bun.SQL or postgres-js Sql)");
|
|
193
|
+
}
|
|
194
|
+
return client.reserve();
|
|
195
|
+
}
|
|
189
196
|
export async function fetchAppliedMigrations(db) {
|
|
190
|
-
|
|
197
|
+
return fetchAppliedFrom(migrationClient(db));
|
|
198
|
+
}
|
|
199
|
+
async function fetchAppliedFrom(client) {
|
|
200
|
+
const result = await client.unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
|
|
191
201
|
const rows = Array.isArray(result) ? result : [];
|
|
192
202
|
const applied = [];
|
|
193
203
|
for (const row of rows) {
|
|
@@ -216,40 +226,55 @@ export class MigrationChecksumMismatchError extends Error {
|
|
|
216
226
|
// _kumiko_migrations-Lookup übersprungen. Checksum-Mismatch wirft fail-loud.
|
|
217
227
|
// Advisory-Lock verhindert concurrent-apply-races.
|
|
218
228
|
export async function runMigrations(db, migrations) {
|
|
219
|
-
|
|
220
|
-
|
|
229
|
+
// Session-level advisory lock and per-migration BEGIN/COMMIT must share one
|
|
230
|
+
// session. Pool calls may land on different connections (Bun.SQL round-robins),
|
|
231
|
+
// which would leave the lock held on a session nobody unlocks.
|
|
232
|
+
const conn = await reserveConnection(db);
|
|
221
233
|
try {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
234
|
+
await conn.unsafe(MIGRATIONS_TABLE_DDL);
|
|
235
|
+
await conn.unsafe(`SELECT pg_advisory_lock(${MIGRATION_ADVISORY_LOCK_KEY})`);
|
|
236
|
+
try {
|
|
237
|
+
const applied = new Map((await fetchAppliedFrom(conn)).map((a) => [a.id, a.checksum]));
|
|
238
|
+
const appliedIds = [];
|
|
239
|
+
const skippedIds = [];
|
|
240
|
+
for (const m of migrations) {
|
|
241
|
+
const prevChecksum = applied.get(m.id);
|
|
242
|
+
if (prevChecksum !== undefined) {
|
|
243
|
+
if (prevChecksum !== m.checksum) {
|
|
244
|
+
throw new MigrationChecksumMismatchError(m.id, prevChecksum, m.checksum);
|
|
245
|
+
}
|
|
246
|
+
skippedIds.push(m.id);
|
|
247
|
+
continue;
|
|
230
248
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
249
|
+
// Apply file content + INSERT tracking-row in einer TX. Wenn ein
|
|
250
|
+
// Statement bricht, Rollback inkl. Tracking-Row → kein partial-apply
|
|
251
|
+
// stuck in den books.
|
|
252
|
+
await conn.unsafe("BEGIN");
|
|
253
|
+
try {
|
|
254
|
+
for (const stmt of m.statements) {
|
|
255
|
+
await conn.unsafe(stmt);
|
|
256
|
+
}
|
|
257
|
+
await conn.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [
|
|
258
|
+
m.id,
|
|
259
|
+
m.checksum,
|
|
260
|
+
]);
|
|
261
|
+
await conn.unsafe("COMMIT");
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
// A failed ROLLBACK means the session is gone (its lock and tx with it); keep the migration error.
|
|
265
|
+
await conn.unsafe("ROLLBACK").catch(() => undefined);
|
|
266
|
+
throw e;
|
|
241
267
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
268
|
+
appliedIds.push(m.id);
|
|
269
|
+
}
|
|
270
|
+
return { applied: appliedIds, skipped: skippedIds };
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await conn.unsafe(`SELECT pg_advisory_unlock(${MIGRATION_ADVISORY_LOCK_KEY})`);
|
|
248
274
|
}
|
|
249
|
-
return { applied: appliedIds, skipped: skippedIds };
|
|
250
275
|
}
|
|
251
276
|
finally {
|
|
252
|
-
await
|
|
277
|
+
await conn.release();
|
|
253
278
|
}
|
|
254
279
|
}
|
|
255
280
|
// Convenience: load + run in einem Call. Wird vom `kumiko migrate apply`
|
|
@@ -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) {
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|