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