@cosmicdrift/kumiko-framework 0.363.0 → 0.364.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +7 -0
- 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/routes.js +46 -17
- 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 +8 -3
- package/dist/bun-db/query.js +27 -7
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/eagerload.js +2 -2
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +24 -22
- package/dist/db/event-store-executor-read.js +31 -16
- 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 +1 -1
- package/dist/db/migrate-runner.js +9 -8
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- 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 +2 -0
- package/dist/db/tenant-db.js +30 -4
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/soft-delete-cleanup.js +10 -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 +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- 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/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 +1 -1
- 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/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +8 -4
- 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-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +99 -22
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +36 -9
- package/package.json +7 -5
- package/src/changes.json +207 -8
package/dist/bun-db/query.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// Static import (not getTemporal()): coercion works without an installed
|
|
22
22
|
// global and returns the same Temporal classes as the rest of the repo (#1480).
|
|
23
23
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
24
|
-
import { isOneOf,
|
|
24
|
+
import { isOneOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
25
25
|
import { requestContext } from "../api/request-context.js";
|
|
26
26
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index.js";
|
|
27
27
|
import { isPiiCiphertext } from "../crypto/pii-ciphertext-format.js";
|
|
@@ -50,6 +50,9 @@ function snakeToCamel(key) {
|
|
|
50
50
|
// would overwrite the matching meta key — reading the meta from the symbol is
|
|
51
51
|
// the only collision-safe path.
|
|
52
52
|
export { asEntityTableMeta, requireEntityTableMeta };
|
|
53
|
+
export function hasBegin(client) {
|
|
54
|
+
return typeof client.begin === "function";
|
|
55
|
+
}
|
|
53
56
|
function isRawClient(value) {
|
|
54
57
|
return typeof propertyOf(value, "unsafe") === "function";
|
|
55
58
|
}
|
|
@@ -77,6 +80,9 @@ export function asRawClient(db) {
|
|
|
77
80
|
return sessionClient;
|
|
78
81
|
throw new Error("bun-db: db argument has no .unsafe() — pass Bun.SQL, postgres-js Sql, or a transaction handle.");
|
|
79
82
|
}
|
|
83
|
+
export function runnerCanBeginTransaction(db) {
|
|
84
|
+
return hasBegin(asRawClient(db));
|
|
85
|
+
}
|
|
80
86
|
// postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
|
|
81
87
|
// it errored — even if the JS error was caught (the driver tracks per-block
|
|
82
88
|
// query failures). Error confinement therefore must go through the driver's
|
|
@@ -329,8 +335,10 @@ function plainDateFromDriver(value, column) {
|
|
|
329
335
|
//
|
|
330
336
|
// Unknown columns (computed/aliased) pass through unchanged.
|
|
331
337
|
export function coerceRow(row, info) {
|
|
332
|
-
|
|
333
|
-
|
|
338
|
+
// @cast-boundary db-row — the driver row (raw column names/types) becomes the caller's entity row shape here, and only here
|
|
339
|
+
return coerceDriverRow(row, info);
|
|
340
|
+
}
|
|
341
|
+
function coerceDriverRow(row, info) {
|
|
334
342
|
const out = {};
|
|
335
343
|
let changed = false;
|
|
336
344
|
for (const key of Object.keys(row)) {
|
|
@@ -400,7 +408,7 @@ export function coerceRow(row, info) {
|
|
|
400
408
|
changed = true;
|
|
401
409
|
out[fieldName] = coerced;
|
|
402
410
|
}
|
|
403
|
-
return
|
|
411
|
+
return changed ? out : row;
|
|
404
412
|
}
|
|
405
413
|
function coerceRows(rows, info) {
|
|
406
414
|
return rows.map((r) => coerceRow(r, info));
|
|
@@ -547,8 +555,14 @@ function buildWhereClause(info, where, startIndex, qualifier) {
|
|
|
547
555
|
throw new Error(`where: jsonText requires a jsonb column, "${col}" is ${pgType}`);
|
|
548
556
|
}
|
|
549
557
|
const matches = isJsonTextMatchList(value.jsonText) ? value.jsonText : [value.jsonText];
|
|
558
|
+
if (matches.length === 0) {
|
|
559
|
+
conditions.push("FALSE");
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
550
562
|
for (const match of matches) {
|
|
551
|
-
if (
|
|
563
|
+
if (match === null ||
|
|
564
|
+
typeof match !== "object" ||
|
|
565
|
+
!Array.isArray(match.keys) ||
|
|
552
566
|
match.keys.length === 0 ||
|
|
553
567
|
!match.keys.every((k) => typeof k === "string") ||
|
|
554
568
|
typeof match.eq !== "string") {
|
|
@@ -1376,12 +1390,18 @@ export async function deleteManyBatched(db, table, where, options) {
|
|
|
1376
1390
|
return { deleted, batches };
|
|
1377
1391
|
}
|
|
1378
1392
|
export async function transaction(db, fn) {
|
|
1379
|
-
|
|
1393
|
+
const raw = asRawClient(db);
|
|
1394
|
+
if (!hasBegin(raw)) {
|
|
1395
|
+
throw new InternalError({
|
|
1396
|
+
message: "transaction: runner has no begin() — already inside a transaction or savepoint.",
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
return raw.begin(fn);
|
|
1380
1400
|
}
|
|
1381
1401
|
// A tx/savepoint handle has no begin(); fail closed instead of nesting silently or TypeError-ing.
|
|
1382
1402
|
export async function runInNewTransaction(db, fn) {
|
|
1383
1403
|
const raw = asRawClient(db);
|
|
1384
|
-
if (
|
|
1404
|
+
if (!hasBegin(raw)) {
|
|
1385
1405
|
throw new InternalError({
|
|
1386
1406
|
message: "runInNewTransaction: runner has no begin() — already inside a transaction or savepoint.",
|
|
1387
1407
|
});
|
|
@@ -15,6 +15,15 @@ function canonicalColumnsKey(meta) {
|
|
|
15
15
|
.map((c) => `${c.name}|${c.pgType}|${c.notNull}|${c.defaultSql ?? ""}|${c.primaryKey ?? false}|${c.identity ?? false}|${c.bigintJsMode ?? ""}`)
|
|
16
16
|
.join("\n");
|
|
17
17
|
}
|
|
18
|
+
function registerPass1Meta(metas, byName, meta, origin) {
|
|
19
|
+
const existing = byName.get(meta.tableName);
|
|
20
|
+
if (existing) {
|
|
21
|
+
throw new Error(`collectTableMetas: table "${meta.tableName}" is registered by ${origin} and ${existing.origin}. ` +
|
|
22
|
+
"Give one of them a different table name.");
|
|
23
|
+
}
|
|
24
|
+
metas.push(meta);
|
|
25
|
+
byName.set(meta.tableName, { meta, origin });
|
|
26
|
+
}
|
|
18
27
|
export function collectTableMetas(features) {
|
|
19
28
|
const metas = [];
|
|
20
29
|
const byName = new Map();
|
|
@@ -37,15 +46,10 @@ export function collectTableMetas(features) {
|
|
|
37
46
|
assertBackingTableSuperset(name, fieldMeta, tableMeta);
|
|
38
47
|
meta = tableMeta;
|
|
39
48
|
}
|
|
40
|
-
metas.
|
|
41
|
-
byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
|
|
49
|
+
registerPass1Meta(metas, byName, meta, `entity "${name}" (${feature.name})`);
|
|
42
50
|
}
|
|
43
51
|
for (const entry of Object.values(feature.storeTables)) {
|
|
44
|
-
metas.
|
|
45
|
-
byName.set(entry.meta.tableName, {
|
|
46
|
-
meta: entry.meta,
|
|
47
|
-
origin: `storeTable "${entry.name}" (${feature.name})`,
|
|
48
|
-
});
|
|
52
|
+
registerPass1Meta(metas, byName, entry.meta, `storeTable "${entry.name}" (${feature.name})`);
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
// Pass 2: table-tragende Registrierungen. Zwei Pässe, damit eine Entity-
|
package/dist/db/eagerload.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// Custom handlers must pass the viewer explicitly so the parentRef gate is
|
|
19
19
|
// never skipped silently.
|
|
20
20
|
import { requestContext } from "../api/request-context.js";
|
|
21
|
-
import {
|
|
21
|
+
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
|
|
22
22
|
import { selectMany } from "../db/query.js";
|
|
23
23
|
import { filterReadFields } from "../engine/field-access.js";
|
|
24
24
|
import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
|
|
@@ -162,7 +162,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
162
162
|
const rawRefRows = scope.kind === "restricted"
|
|
163
163
|
? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
|
|
164
164
|
: lookedUpRows;
|
|
165
|
-
const piiFields =
|
|
165
|
+
const piiFields = collectPiiSubjectFields(refEntity);
|
|
166
166
|
const encryptedFields = collectEncryptedFieldNames(refEntity);
|
|
167
167
|
const kms = configuredPiiSubjectKms();
|
|
168
168
|
const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
|
|
@@ -33,15 +33,14 @@ export type ExecutorContext = {
|
|
|
33
33
|
readonly includeDeleted?: boolean;
|
|
34
34
|
}) => Promise<Record<string, unknown> | null>;
|
|
35
35
|
readonly assertStreamWritable: (db: TenantDb, id: EntityId, tenantId: TenantId) => Promise<void>;
|
|
36
|
-
readonly
|
|
37
|
-
kind: "pass";
|
|
38
|
-
} | {
|
|
39
|
-
kind: "empty";
|
|
40
|
-
} | {
|
|
36
|
+
readonly probeOwnershipWithStreamVersion: (db: TenantDb, idWhere: WhereObject, ownership: {
|
|
41
37
|
kind: "sql";
|
|
42
38
|
sqlText: string;
|
|
43
39
|
params: readonly unknown[];
|
|
44
|
-
}) => Promise<
|
|
40
|
+
}, streamTenantId: TenantId, aggregateId: string) => Promise<{
|
|
41
|
+
readonly owned: boolean;
|
|
42
|
+
readonly streamVersion: number;
|
|
43
|
+
}>;
|
|
45
44
|
readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
|
|
46
45
|
kind: "pass";
|
|
47
46
|
} | {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context.js";
|
|
2
|
-
import { collectPiiEncryptedFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
2
|
+
import { collectPiiEncryptedFields, collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
|
|
3
3
|
import { personalFieldsRekeyedOnOwnerTransition } from "../crypto/owner-transition-rekey.js";
|
|
4
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
5
5
|
import { shiftParams } from "../engine/ownership.js";
|
|
@@ -173,6 +173,11 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
173
173
|
const hasEncryptedFields = encryptedFields.size > 0;
|
|
174
174
|
const piiSubjectFields = collectPiiEncryptedFields(entity);
|
|
175
175
|
const hasPiiFields = piiSubjectFields.length > 0;
|
|
176
|
+
// Decrypt keeps file-reference fields: rows and event payloads written by
|
|
177
|
+
// the old backfill may hold ciphertext there, and decrypt passes plaintext
|
|
178
|
+
// through untouched.
|
|
179
|
+
const piiDecryptFields = collectPiiSubjectFields(entity);
|
|
180
|
+
const hasPiiDecryptFields = piiDecryptFields.length > 0;
|
|
176
181
|
function fieldCipher() {
|
|
177
182
|
if (options.encryption)
|
|
178
183
|
return options.encryption;
|
|
@@ -230,8 +235,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
230
235
|
async function decryptForRead(row, opts) {
|
|
231
236
|
let out = row;
|
|
232
237
|
const kms = piiKms();
|
|
233
|
-
if (
|
|
234
|
-
out = await decryptPiiFieldValues(out,
|
|
238
|
+
if (hasPiiDecryptFields && kms) {
|
|
239
|
+
out = await decryptPiiFieldValues(out, piiDecryptFields, kms, kmsContextFor());
|
|
235
240
|
}
|
|
236
241
|
if (hasEncryptedFields) {
|
|
237
242
|
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher(), opts?.degradeUndecryptable
|
|
@@ -301,24 +306,6 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
301
306
|
throw new ArchivedStreamError(tenantId, String(id));
|
|
302
307
|
}
|
|
303
308
|
}
|
|
304
|
-
// SELECT a row by id with the ownership clause applied at the DB layer.
|
|
305
|
-
// Detail() uses this both on cold path and as a cache-revalidation probe.
|
|
306
|
-
async function loadWithOwnership(db, idWhere, ownership) {
|
|
307
|
-
if (ownership.kind === "empty")
|
|
308
|
-
return [];
|
|
309
|
-
if (ownership.kind === "pass") {
|
|
310
|
-
const row = await db.fetchOne(table, idWhere);
|
|
311
|
-
return row ? [row] : [];
|
|
312
|
-
}
|
|
313
|
-
// ownership has raw SQL — splice it into a raw query alongside the
|
|
314
|
-
// idFilter + tenant-filter that TenantDb would have added.
|
|
315
|
-
const tableName = String(schemaTableName(table));
|
|
316
|
-
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
317
|
-
const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
318
|
-
return [
|
|
319
|
-
...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
|
|
320
|
-
];
|
|
321
|
-
}
|
|
322
309
|
// Tenant filter + idFilter + ownership predicate as raw SQL, mirroring what
|
|
323
310
|
// TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
|
|
324
311
|
// `isDeleted`, so no null/IS NULL branch is needed.
|
|
@@ -351,6 +338,21 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
351
338
|
return (`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${aggregateIdParam} ` +
|
|
352
339
|
`AND "tenant_id" = $${tenantIdParam}) AS "__streamVersion"`);
|
|
353
340
|
}
|
|
341
|
+
// Cache-hit revalidation: ownership predicate and live stream version in ONE
|
|
342
|
+
// statement, so a cache hit costs the same single roundtrip as the cold path.
|
|
343
|
+
async function probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantId, aggregateId) {
|
|
344
|
+
const tableName = String(schemaTableName(table));
|
|
345
|
+
const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
|
|
346
|
+
params.push(aggregateId, streamTenantId);
|
|
347
|
+
const sqlText = `SELECT 1 AS "__owned", ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
|
|
348
|
+
`FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
|
|
349
|
+
const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
|
|
350
|
+
const first = rows[0];
|
|
351
|
+
if (!first)
|
|
352
|
+
return { owned: false, streamVersion: 0 };
|
|
353
|
+
const version = first["__streamVersion"];
|
|
354
|
+
return { owned: true, streamVersion: typeof version === "number" ? version : 0 };
|
|
355
|
+
}
|
|
354
356
|
// Row and MAX(version) in ONE statement: two reads can pair a post-write
|
|
355
357
|
// version with a pre-write row, and update({version: detail.version}) would
|
|
356
358
|
// then pass the optimistic lock and overwrite the newer write (lost update).
|
|
@@ -431,7 +433,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
431
433
|
idFilter,
|
|
432
434
|
loadById,
|
|
433
435
|
assertStreamWritable,
|
|
434
|
-
|
|
436
|
+
probeOwnershipWithStreamVersion,
|
|
435
437
|
loadDetailSnapshot,
|
|
436
438
|
loadExpectSnapshot,
|
|
437
439
|
encryptForStorage,
|
|
@@ -6,7 +6,7 @@ import { coerceRow, extractTableInfo } from "../db/query.js";
|
|
|
6
6
|
import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
8
8
|
import { UnprocessableError } from "../errors/index.js";
|
|
9
|
-
import { getStreamVersion
|
|
9
|
+
import { getStreamVersion } from "../event-store/index.js";
|
|
10
10
|
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
|
|
11
11
|
import { rehydrateCompoundTypes } from "./compound-types.js";
|
|
12
12
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
@@ -387,6 +387,7 @@ function referenceTargetTable(name, entity) {
|
|
|
387
387
|
// Projected alongside the row when a reference sort is active, so the keyset
|
|
388
388
|
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
389
389
|
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
390
|
+
const STREAM_VERSION_ALIAS = "__streamVersion";
|
|
390
391
|
// fw#2741 — resolve a `sortable: true` reference field's label inline for
|
|
391
392
|
// ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
|
|
392
393
|
// row per entity row, the read model stays untouched, and the lookup rides the
|
|
@@ -448,7 +449,7 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
|
|
|
448
449
|
};
|
|
449
450
|
}
|
|
450
451
|
export function createReadVerbs(ctx) {
|
|
451
|
-
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter,
|
|
452
|
+
const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, probeOwnershipWithStreamVersion, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
|
|
452
453
|
return {
|
|
453
454
|
// list + detail are unchanged from crud-executor — projections are the
|
|
454
455
|
// read-model and serve these queries directly.
|
|
@@ -644,15 +645,28 @@ export function createReadVerbs(ctx) {
|
|
|
644
645
|
const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
|
|
645
646
|
// Project the resolved label too, so the keyset cursor below carries the
|
|
646
647
|
// value that was ordered by rather than the FK column's UUID.
|
|
647
|
-
const
|
|
648
|
-
?
|
|
649
|
-
: "
|
|
648
|
+
const sortLabelProjection = referenceSortExpr !== undefined && sortSql !== undefined
|
|
649
|
+
? `, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
|
|
650
|
+
: "";
|
|
651
|
+
// Stream version in the same statement as the rows: a separate read could pair a
|
|
652
|
+
// post-write version with a pre-write row, and update({version}) would then pass
|
|
653
|
+
// the optimistic lock against a state the client never saw (see loadDetailSnapshot).
|
|
654
|
+
params.push(streamTenantFor(user));
|
|
655
|
+
const streamVersionProjection = `, (SELECT MAX("version") FROM "kumiko_events" ` +
|
|
656
|
+
`WHERE "aggregate_id" = "${tableName}"."id" AND "tenant_id" = $${params.length}) ` +
|
|
657
|
+
`AS "${STREAM_VERSION_ALIAS}"`;
|
|
658
|
+
const selectList = `*${sortLabelProjection}${streamVersionProjection}`;
|
|
650
659
|
const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
651
660
|
const rawRows = await executeRawQueryRead(runner, listSql, params);
|
|
652
661
|
const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
|
|
662
|
+
// Aliases are query artifacts, not columns of this entity — strip them before
|
|
663
|
+
// coercion so they never reach the caller or the entity cache.
|
|
664
|
+
const streamVersions = rawRows.map((r) => {
|
|
665
|
+
const v = r[STREAM_VERSION_ALIAS];
|
|
666
|
+
delete r[STREAM_VERSION_ALIAS];
|
|
667
|
+
return typeof v === "number" ? v : 0;
|
|
668
|
+
});
|
|
653
669
|
if (referenceSortExpr !== undefined) {
|
|
654
|
-
// The alias is a sort artifact, not a column of this entity — strip it
|
|
655
|
-
// before coercion so it never reaches the caller or the entity cache.
|
|
656
670
|
for (const row of rawRows)
|
|
657
671
|
delete row[SORT_LABEL_ALIAS];
|
|
658
672
|
}
|
|
@@ -668,10 +682,7 @@ export function createReadVerbs(ctx) {
|
|
|
668
682
|
// Stream version is authoritative (see detail()): ctx.appendEvent bumps the
|
|
669
683
|
// stream without touching row.version, so the raw row version would hand
|
|
670
684
|
// clients a stale optimistic-lock base. Rows without events keep theirs.
|
|
671
|
-
const
|
|
672
|
-
? await getStreamVersions(runner, decryptedRows.map((r) => String(r["id"])), streamTenantFor(user))
|
|
673
|
-
: new Map();
|
|
674
|
-
const rows = decryptedRows.map((r) => withStreamVersion(r, streamVersions.get(String(r["id"])) ?? 0));
|
|
685
|
+
const rows = decryptedRows.map((r, i) => withStreamVersion(r, streamVersions[i] ?? 0));
|
|
675
686
|
// Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
|
|
676
687
|
// detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
|
|
677
688
|
// a cached version that differs from the stream.
|
|
@@ -730,16 +741,20 @@ export function createReadVerbs(ctx) {
|
|
|
730
741
|
if (entityCache && entityName) {
|
|
731
742
|
const cached = await entityCache.get(user.tenantId, entityName, payload.id);
|
|
732
743
|
if (cached) {
|
|
744
|
+
let streamVersion;
|
|
733
745
|
if (ownership.kind === "sql") {
|
|
734
746
|
// Re-check ownership predicate (incl. the parent-ref gate) against
|
|
735
747
|
// the live row — the cache is keyed only by tenant + id, not by
|
|
736
|
-
// role, so a cache hit can't bypass either gate.
|
|
737
|
-
//
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
748
|
+
// role, so a cache hit can't bypass either gate. The probe carries
|
|
749
|
+
// the stream version in the same statement.
|
|
750
|
+
const probe = await probeOwnershipWithStreamVersion(db, idWhere, ownership, streamTenantFor(user), String(payload.id));
|
|
751
|
+
if (!probe.owned)
|
|
740
752
|
return null;
|
|
753
|
+
streamVersion = probe.streamVersion;
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
|
|
741
757
|
}
|
|
742
|
-
const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
|
|
743
758
|
if (!(streamVersion > 0 && cached["version"] !== streamVersion)) {
|
|
744
759
|
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
745
760
|
// `encrypted` field's plaintext never sits in a second at-rest
|
|
@@ -3,7 +3,6 @@ import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
|
3
3
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
4
4
|
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
5
5
|
import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
|
|
6
|
-
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
7
6
|
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
8
7
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
9
8
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
@@ -12,10 +11,11 @@ import { generateId } from "../utils/index.js";
|
|
|
12
11
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
13
12
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
14
13
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
14
|
+
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
15
15
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
16
16
|
import { runInSavepointIfSupported } from "./query.js";
|
|
17
17
|
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
|
|
18
|
-
import {
|
|
18
|
+
import { tenantDbWriteRunner } from "./tenant-db-runner.js";
|
|
19
19
|
// Custom projections for a write that did not go through the dispatcher (hook or
|
|
20
20
|
// job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
|
|
21
21
|
// projection fails the write and the caller's transaction rolls it back.
|
|
@@ -29,12 +29,6 @@ async function projectEntityEvent(db, runner, event) {
|
|
|
29
29
|
return;
|
|
30
30
|
await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
|
|
31
31
|
}
|
|
32
|
-
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
33
|
-
// per-role ownership map can never cover it, and a silent deny means the
|
|
34
|
-
// erasure never happened.
|
|
35
|
-
function isFrameworkSystemUser(user) {
|
|
36
|
-
return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
|
|
37
|
-
}
|
|
38
32
|
// The five write verbs (create/update/delete/forget/restore) of the event-
|
|
39
33
|
// store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
|
|
40
34
|
// behavior-preserving relocation, not a redesign: every closure below is
|
|
@@ -126,9 +120,7 @@ export function createWriteVerbs(ctx) {
|
|
|
126
120
|
// statutory deletion must reach locked rows, and forget (Art. 17) never checks the lock
|
|
127
121
|
// at all. The options are server-code only; no request field is ever copied into them.
|
|
128
122
|
function recordLockedFailure(row, id, user, options) {
|
|
129
|
-
if (options
|
|
130
|
-
(options?.writePurpose !== undefined && isFrameworkSystemUser(user)) ||
|
|
131
|
-
!isRecordLocked(entity.lockedWhen, row)) {
|
|
123
|
+
if (isLockGuardBypassed(user, options) || !isRecordLocked(entity.lockedWhen, row)) {
|
|
132
124
|
return undefined;
|
|
133
125
|
}
|
|
134
126
|
return writeFailure(new RecordLockedError({ entityName, entityId: id }));
|
|
@@ -180,7 +172,7 @@ export function createWriteVerbs(ctx) {
|
|
|
180
172
|
const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
|
|
181
173
|
if (streamTenantMismatch)
|
|
182
174
|
return streamTenantMismatch;
|
|
183
|
-
const runner =
|
|
175
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor create");
|
|
184
176
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
185
177
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
186
178
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
@@ -349,7 +341,7 @@ export function createWriteVerbs(ctx) {
|
|
|
349
341
|
};
|
|
350
342
|
},
|
|
351
343
|
async update(payload, user, db, updateOptions) {
|
|
352
|
-
const runner =
|
|
344
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor update");
|
|
353
345
|
if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
|
|
354
346
|
throw new AccessDeniedError({
|
|
355
347
|
message: `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
|
|
@@ -582,7 +574,7 @@ export function createWriteVerbs(ctx) {
|
|
|
582
574
|
if (isIrreversibleEntityVerb("delete", entity)) {
|
|
583
575
|
assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
|
|
584
576
|
}
|
|
585
|
-
const runner =
|
|
577
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor delete");
|
|
586
578
|
const existing = await loadById(payload.id, db);
|
|
587
579
|
if (!existing)
|
|
588
580
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -664,6 +656,7 @@ export function createWriteVerbs(ctx) {
|
|
|
664
656
|
data: existing,
|
|
665
657
|
entityName,
|
|
666
658
|
event: echoEvent,
|
|
659
|
+
...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
|
|
667
660
|
},
|
|
668
661
|
};
|
|
669
662
|
},
|
|
@@ -673,7 +666,7 @@ export function createWriteVerbs(ctx) {
|
|
|
673
666
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
674
667
|
async forget(payload, user, db, options) {
|
|
675
668
|
assertIrreversibleOperationAllowed(`${entityName}.forget`);
|
|
676
|
-
const runner =
|
|
669
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor forget");
|
|
677
670
|
const raw = await db.fetchOne(table, { id: payload.id });
|
|
678
671
|
if (!raw)
|
|
679
672
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -744,6 +737,7 @@ export function createWriteVerbs(ctx) {
|
|
|
744
737
|
data: existing,
|
|
745
738
|
entityName,
|
|
746
739
|
event: echoEvent,
|
|
740
|
+
lockGuardBypassed: true,
|
|
747
741
|
},
|
|
748
742
|
};
|
|
749
743
|
},
|
|
@@ -753,7 +747,7 @@ export function createWriteVerbs(ctx) {
|
|
|
753
747
|
i18nKey: "errors.softDeleteNotEnabled",
|
|
754
748
|
}));
|
|
755
749
|
}
|
|
756
|
-
const runner =
|
|
750
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
|
|
757
751
|
const streamTenantId = options?.streamTenantId;
|
|
758
752
|
const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
|
|
759
753
|
if ("failure" in target)
|
|
@@ -780,8 +774,13 @@ export function createWriteVerbs(ctx) {
|
|
|
780
774
|
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
781
775
|
// delete for symmetry. Projections that decremented on delete use
|
|
782
776
|
// `previous` to re-increment on restore without re-querying the entity
|
|
783
|
-
// table.
|
|
784
|
-
//
|
|
777
|
+
// table. Rows predating the subject-KMS rollout may still hold plaintext
|
|
778
|
+
// pii, and envelope encryption is not idempotent: decrypt, then
|
|
779
|
+
// re-encrypt as delete does.
|
|
780
|
+
const previousPlain = await decryptForRead(data);
|
|
781
|
+
const previousForStorage = await encryptForStorage(previousPlain, user, {
|
|
782
|
+
keyTenantId: streamTenantId,
|
|
783
|
+
});
|
|
785
784
|
let event;
|
|
786
785
|
try {
|
|
787
786
|
event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
@@ -791,7 +790,7 @@ export function createWriteVerbs(ctx) {
|
|
|
791
790
|
expectedVersion: currentVersion,
|
|
792
791
|
type: entityEventName(entityName, "restored"),
|
|
793
792
|
eventVersion,
|
|
794
|
-
payload: { previous:
|
|
793
|
+
payload: { previous: previousForStorage },
|
|
795
794
|
metadata: buildEventMetadata(user),
|
|
796
795
|
}));
|
|
797
796
|
}
|
|
@@ -835,7 +834,6 @@ export function createWriteVerbs(ctx) {
|
|
|
835
834
|
// same as every other executor method — `data`/`restored` are raw rows
|
|
836
835
|
// (db.fetchOne / applyEntityEvent), never decrypted before this point.
|
|
837
836
|
const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
|
|
838
|
-
const previousPlain = await decryptForRead(data);
|
|
839
837
|
const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
|
|
840
838
|
await projectEntityEvent(db, runner, echoEvent);
|
|
841
839
|
return {
|
package/dist/db/index.d.ts
CHANGED
|
@@ -35,4 +35,4 @@ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFramework
|
|
|
35
35
|
export { tableExists } from "./schema-inspection.js";
|
|
36
36
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
37
37
|
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
|
|
38
|
-
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
|
|
38
|
+
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
package/dist/db/index.js
CHANGED
|
@@ -27,4 +27,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
|
|
|
27
27
|
export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
28
28
|
export { tableExists } from "./schema-inspection.js";
|
|
29
29
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
30
|
-
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
|
|
30
|
+
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SessionUser } from "../engine/types/index.js";
|
|
2
|
+
import type { LockGuardOption } from "./event-store-executor.js";
|
|
3
|
+
export declare function isFrameworkSystemUser(user: SessionUser): boolean;
|
|
4
|
+
export declare function isLockGuardBypassed(user: SessionUser, options: LockGuardOption | undefined): boolean;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
2
|
+
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
3
|
+
// per-role ownership map can never cover it, and a silent deny means the
|
|
4
|
+
// erasure never happened.
|
|
5
|
+
export function isFrameworkSystemUser(user) {
|
|
6
|
+
return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
|
|
7
|
+
}
|
|
8
|
+
// Framework system writes that name a writePurpose (retention, erasure) are exempt:
|
|
9
|
+
// statutory deletion must reach locked rows. The options are server-code only; no
|
|
10
|
+
// request field is ever copied into them.
|
|
11
|
+
export function isLockGuardBypassed(user, options) {
|
|
12
|
+
return (options?.unsafeSkipLockGuard === true ||
|
|
13
|
+
(options?.writePurpose !== undefined && isFrameworkSystemUser(user)));
|
|
14
|
+
}
|
|
@@ -437,7 +437,7 @@ export function assertUniqueTableNames(metas) {
|
|
|
437
437
|
const duplicates = [...counts].filter(([, count]) => count > 1);
|
|
438
438
|
if (duplicates.length > 0) {
|
|
439
439
|
throw new Error(duplicates
|
|
440
|
-
.map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the
|
|
440
|
+
.map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the duplicate entry for "${tableName}" (a hand-added meta, or two features registering the same table).`)
|
|
441
441
|
.join("\n"));
|
|
442
442
|
}
|
|
443
443
|
}
|
|
@@ -17,7 +17,7 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
20
|
-
import { asRawClient } from "../bun-db/query.js";
|
|
20
|
+
import { asRawClient, hasBegin } from "../bun-db/query.js";
|
|
21
21
|
// Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
|
|
22
22
|
// gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
|
|
23
23
|
// blockiert bis erster fertig).
|
|
@@ -169,17 +169,18 @@ export function readMigrationSqlTexts(dir) {
|
|
|
169
169
|
// without it fall through to the drizzle wrappers and otherwise fail here,
|
|
170
170
|
// before any SQL runs, with the migrate-runner message.
|
|
171
171
|
function migrationClient(db) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return asRawClient(db);
|
|
175
|
-
for (const wrapped of [
|
|
172
|
+
for (const candidate of [
|
|
173
|
+
db,
|
|
176
174
|
propertyOf(db, "$client"),
|
|
177
175
|
propertyOf(propertyOf(db, "session"), "client"),
|
|
178
176
|
]) {
|
|
179
|
-
if (typeof propertyOf(
|
|
180
|
-
|
|
177
|
+
if (typeof propertyOf(candidate, "unsafe") !== "function")
|
|
178
|
+
continue;
|
|
179
|
+
const client = asRawClient(candidate);
|
|
180
|
+
if (hasBegin(client))
|
|
181
|
+
return client;
|
|
181
182
|
}
|
|
182
|
-
throw new Error("migrate-runner: db argument has no .unsafe() (need Bun.SQL or drizzle DbConnection)");
|
|
183
|
+
throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need Bun.SQL or drizzle DbConnection)");
|
|
183
184
|
}
|
|
184
185
|
// Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
|
|
185
186
|
async function executeRaw(db, sqlText) {
|
|
@@ -13,7 +13,7 @@ export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readon
|
|
|
13
13
|
readonly name: string;
|
|
14
14
|
readonly instanceId: string;
|
|
15
15
|
}>>;
|
|
16
|
-
export declare function selectConsumerForUpdateSkipLocked
|
|
16
|
+
export declare function selectConsumerForUpdateSkipLocked(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
|
|
17
17
|
export declare function markConsumerProcessing(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
18
18
|
export declare function recordConsumerPassFailure(db: AnyDb, name: string, instanceId: string, errorMessage: string, cursorBeforePass?: bigint): Promise<void>;
|
|
19
19
|
export type ConsumerDeliveryOutcome = {
|
|
@@ -26,11 +26,11 @@ export type ConsumerDeliveryOutcome = {
|
|
|
26
26
|
readonly failedEventId: bigint | null;
|
|
27
27
|
};
|
|
28
28
|
export declare function updateConsumerDeliveryOutcome(db: AnyDb, name: string, instanceId: string, outcome: ConsumerDeliveryOutcome): Promise<void>;
|
|
29
|
-
export declare function updateConsumerStatusReturning
|
|
30
|
-
export declare function advanceConsumerPastEventReturning
|
|
29
|
+
export declare function updateConsumerStatusReturning(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<Record<string, unknown> | undefined>;
|
|
30
|
+
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
|
|
31
31
|
export declare function resetConsumerForMspRebuild(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
32
|
-
export declare function selectConsumerForUpdate
|
|
32
|
+
export declare function selectConsumerForUpdate(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
|
|
33
33
|
export declare function updateConsumerRebuildCursor(db: AnyDb, name: string, instanceId: string, lastProcessedEventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<void>;
|
|
34
34
|
export declare function markConsumerRebuildFailed(db: AnyDb, name: string, instanceId: string, errorMessage: string): Promise<void>;
|
|
35
|
-
export declare function rearmDeadConsumer
|
|
36
|
-
export declare function removePendingGapReturning
|
|
35
|
+
export declare function rearmDeadConsumer(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
|
|
36
|
+
export declare function removePendingGapReturning(db: AnyDb, name: string, instanceId: string, newPendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
|
|
@@ -56,5 +56,5 @@ export type PendingIdRange = {
|
|
|
56
56
|
readonly from: bigint;
|
|
57
57
|
readonly to: bigint;
|
|
58
58
|
};
|
|
59
|
-
export declare function selectPendingAndNewEventRows
|
|
59
|
+
export declare function selectPendingAndNewEventRows(db: AnyDb, cursor: bigint, pendingRanges: readonly PendingIdRange[], batchSize: number): Promise<ReadonlyArray<Record<string, unknown>>>;
|
|
60
60
|
export declare function selectSmallestVisibleIdInRanges(db: AnyDb, pendingRanges: readonly PendingIdRange[]): Promise<bigint | null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AnyDb } from "../query.js";
|
|
2
2
|
export declare function markProjectionRebuilding(db: AnyDb, projectionName: string): Promise<void>;
|
|
3
|
-
export declare function selectEventsForProjectionRebuildBatch
|
|
3
|
+
export declare function selectEventsForProjectionRebuildBatch(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[], afterId: bigint, limit: number): Promise<ReadonlyArray<Record<string, unknown>>>;
|
|
4
4
|
export declare function countSubscribedEvents(db: AnyDb, aggregateTypes: readonly string[], eventTypes: readonly string[]): Promise<bigint>;
|
|
5
5
|
export declare function finalizeProjectionRebuild(db: AnyDb, projectionName: string, lastProcessedEventId: bigint): Promise<void>;
|
|
6
6
|
export declare function markProjectionRebuildFailed(db: AnyDb, projectionName: string, errorMessage: string): Promise<void>;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
|
+
import { InternalError } from "../errors/index.js";
|
|
2
3
|
import type { DbRunner } from "./connection.js";
|
|
3
4
|
export declare function bindTenantDbRunner(tenantDb: TenantDb, runner: DbRunner): void;
|
|
4
5
|
export declare function tenantDbRunner(tenantDb: TenantDb): DbRunner;
|
|
6
|
+
export declare function denyTenantDbWrites(tenantDb: TenantDb, reason: string): void;
|
|
7
|
+
export declare function tenantDbWritesDeniedError(operation: string, reason: string): InternalError;
|
|
8
|
+
export declare function tenantDbWriteRunner(tenantDb: TenantDb, operation: string): DbRunner;
|