@cosmicdrift/kumiko-framework 0.378.0 → 0.380.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 +4 -1
- package/agents/examples/compose-stacks.md +1 -2
- package/agents/examples/currencies-global.md +2 -3
- package/agents/examples/embedded.md +23 -37
- package/agents/examples/event-sourcing.md +6 -5
- package/agents/examples/legal-pages.md +5 -5
- package/agents/examples/reference-data.md +26 -21
- package/agents/examples/relations.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +27 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/server.js +10 -3
- package/dist/api/sse-route.d.ts +5 -0
- package/dist/api/sse-route.js +59 -9
- package/dist/db/apply-entity-event.d.ts +3 -2
- package/dist/db/apply-entity-event.js +1 -1
- package/dist/db/dialect.d.ts +2 -1
- package/dist/db/dialect.js +2 -2
- package/dist/db/entity-table-from-registry.js +0 -2
- package/dist/db/entity-table-meta.d.ts +5 -3
- package/dist/db/entity-table-meta.js +6 -15
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +8 -0
- package/dist/db/executor-managed-tables.js +48 -0
- package/dist/db/index.d.ts +6 -4
- package/dist/db/index.js +4 -2
- package/dist/db/money.d.ts +1 -1
- package/dist/db/queries/backfill-pii.js +22 -12
- package/dist/db/queries/database-now.d.ts +5 -0
- package/dist/db/queries/database-now.js +12 -0
- package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
- package/dist/db/queries/event-store-orphan-rows.js +32 -0
- package/dist/db/query.d.ts +22 -11
- package/dist/db/query.js +41 -36
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.d.ts +2 -3
- package/dist/db/table-builder.js +2 -0
- package/dist/db/table-introspection.d.ts +2 -0
- package/dist/db/table-introspection.js +4 -0
- package/dist/db/tenant-db-runner.d.ts +2 -0
- package/dist/db/tenant-db-runner.js +7 -0
- package/dist/db/tenant-db.d.ts +9 -2
- package/dist/db/tenant-db.js +82 -25
- package/dist/engine/boot-validator/entity-handler.js +9 -4
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +34 -9
- package/dist/engine/extensions/tenant-data.d.ts +2 -2
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/projection-helpers.d.ts +3 -3
- package/dist/engine/registry-state.js +2 -0
- package/dist/engine/registry-validate.js +4 -1
- package/dist/engine/schema-builder.js +18 -4
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +3 -0
- package/dist/engine/soft-delete-cleanup.js +53 -24
- package/dist/engine/steps/unsafe-projection-delete.js +4 -2
- package/dist/entrypoint/index.d.ts +1 -3
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +4 -1
- package/dist/errors/serialize.d.ts +1 -0
- package/dist/errors/serialize.js +9 -5
- package/dist/errors/write-error-info.js +3 -2
- package/dist/errors/zod-bridge.js +7 -0
- package/dist/es-ops/operations-schema.d.ts +1 -1
- package/dist/event-store/archive.d.ts +1 -1
- package/dist/event-store/events-schema.d.ts +1 -1
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
- package/dist/event-store/snapshot.d.ts +1 -1
- package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
- package/dist/files/action-form-upload.d.ts +16 -0
- package/dist/files/action-form-upload.js +45 -0
- package/dist/files/bind-pending-file-refs.d.ts +14 -0
- package/dist/files/bind-pending-file-refs.js +44 -4
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +41 -13
- package/dist/files/file-routes.js +29 -2
- package/dist/files/index.d.ts +2 -1
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
- package/dist/files/purge-expired-file-ref-bytes.js +11 -5
- package/dist/files/storage-tracking.d.ts +1 -1
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/internal/testing.d.ts +7 -0
- package/dist/internal/testing.js +14 -1
- package/dist/migrations/pending-rebuilds.d.ts +1 -1
- package/dist/migrations/pii-event-backfill.d.ts +1 -1
- package/dist/migrations/pii-event-backfill.js +14 -7
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +14 -3
- package/dist/pipeline/cascade-handler.js +79 -10
- package/dist/pipeline/dispatch-shared.js +1 -9
- package/dist/pipeline/event-consumer-state.d.ts +1 -1
- package/dist/pipeline/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- package/dist/pipeline/projection-state.d.ts +1 -1
- package/dist/pipeline/projections-runner.js +2 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/middleware.d.ts +4 -0
- package/dist/rate-limit/middleware.js +5 -2
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +2 -2
- package/dist/ui-types/list-row-meta.d.ts +3 -0
- package/dist/ui-types/list-row-meta.js +12 -1
- package/kumiko-es-tables.json +6 -0
- package/package.json +4 -3
- package/src/changes.json +117 -0
- package/src/scripts/codemod/README.md +20 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +416 -0
- package/src/scripts/codemod/package-merge.ts +0 -11
package/dist/db/tenant-db.js
CHANGED
|
@@ -4,8 +4,10 @@ import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, co
|
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
5
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
6
|
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
7
|
+
import { assertWritableTable, executorManagedWriteDenied, hasExecutorManagedWriteCapability, registerExecutorManagedWriteRunner, } from "./executor-managed-tables.js";
|
|
7
8
|
import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
|
|
8
9
|
import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
10
|
+
import { queryDatabaseNow } from "./queries/database-now.js";
|
|
9
11
|
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
10
12
|
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
|
|
11
13
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
@@ -17,8 +19,8 @@ const personalDataGates = new WeakMap();
|
|
|
17
19
|
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate and the projection registry.
|
|
18
20
|
// Keyed by a per-grant proxy, never the shared pool/tx: tagging that would leak onto every sibling TenantDb.
|
|
19
21
|
const runnerPersonalDataGates = new WeakMap();
|
|
20
|
-
function boundRunner(runner, gate, projectionRegistry) {
|
|
21
|
-
if (!gate && !projectionRegistry)
|
|
22
|
+
function boundRunner(runner, gate, projectionRegistry, executorManagedWrites) {
|
|
23
|
+
if (!gate && !projectionRegistry && !executorManagedWrites)
|
|
22
24
|
return runner;
|
|
23
25
|
const proxy = new Proxy(runner, {
|
|
24
26
|
// Tagged-template calls need the real driver object as `this`.
|
|
@@ -33,7 +35,7 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
33
35
|
// Bun.SQL and postgres.js hand back a raw connection; re-wrap it so the gate survives.
|
|
34
36
|
return async (...args) => {
|
|
35
37
|
const reserved = await Reflect.apply(value, target, args);
|
|
36
|
-
return boundRunner(reserved, gate, projectionRegistry);
|
|
38
|
+
return boundRunner(reserved, gate, projectionRegistry, executorManagedWrites);
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
if (prop === "begin" ||
|
|
@@ -48,7 +50,7 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
48
50
|
}
|
|
49
51
|
const gatedArgs = [
|
|
50
52
|
...args.slice(0, -1),
|
|
51
|
-
(tx) => callback(boundRunner(tx, gate, projectionRegistry)),
|
|
53
|
+
(tx) => callback(boundRunner(tx, gate, projectionRegistry, executorManagedWrites)),
|
|
52
54
|
];
|
|
53
55
|
return Reflect.apply(value, target, gatedArgs);
|
|
54
56
|
};
|
|
@@ -60,8 +62,35 @@ function boundRunner(runner, gate, projectionRegistry) {
|
|
|
60
62
|
runnerPersonalDataGates.set(proxy, gate);
|
|
61
63
|
if (projectionRegistry)
|
|
62
64
|
bindProjectionRegistry(proxy, projectionRegistry);
|
|
65
|
+
if (executorManagedWrites) {
|
|
66
|
+
registerExecutorManagedWriteRunner(proxy);
|
|
67
|
+
capableRunnerInners.set(proxy, runner);
|
|
68
|
+
}
|
|
63
69
|
return proxy;
|
|
64
70
|
}
|
|
71
|
+
const capableRunnerInners = new WeakMap();
|
|
72
|
+
const capableRunnerByInner = new WeakMap();
|
|
73
|
+
// Framework-private (not re-exported from db/index.ts): the only way to obtain a runner that may
|
|
74
|
+
// write executor-managed tables. Only the framework's own event-apply paths call it.
|
|
75
|
+
export function withExecutorManagedWrites(runner) {
|
|
76
|
+
if (hasExecutorManagedWriteCapability(runner))
|
|
77
|
+
return runner;
|
|
78
|
+
const cached = capableRunnerByInner.get(runner);
|
|
79
|
+
if (cached)
|
|
80
|
+
return cached;
|
|
81
|
+
const capable = boundRunner(runner, runnerPersonalDataGates.get(runner), projectionRegistryOf(runner), true);
|
|
82
|
+
if (!hasExecutorManagedWriteCapability(capable)) {
|
|
83
|
+
throw new InternalError({
|
|
84
|
+
message: "boundRunner did not register the executor write capability",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
capableRunnerByInner.set(runner, capable);
|
|
88
|
+
return capable;
|
|
89
|
+
}
|
|
90
|
+
// unsafeRaw must not hand out the executor-only write capability, even from a capable runner.
|
|
91
|
+
export function withoutExecutorManagedWrites(runner) {
|
|
92
|
+
return capableRunnerInners.get(runner) ?? runner;
|
|
93
|
+
}
|
|
65
94
|
// The executor passes its entity so the check does not depend on the table-name lookup.
|
|
66
95
|
export function assertPersonalDataWrite(db, tableName, keys, entity) {
|
|
67
96
|
personalDataGates.get(db)?.(tableName, keys, entity);
|
|
@@ -134,8 +163,8 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
134
163
|
}
|
|
135
164
|
// Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
|
|
136
165
|
report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
|
|
137
|
-
const runner = tenantDbRunner(db);
|
|
138
|
-
return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db));
|
|
166
|
+
const runner = withoutExecutorManagedWrites(tenantDbRunner(db));
|
|
167
|
+
return boundRunner(runner, personalDataGates.get(db), projectionRegistryOf(db), false);
|
|
139
168
|
}
|
|
140
169
|
const uncheckedSystemDb = {
|
|
141
170
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
@@ -327,6 +356,10 @@ export async function runInOwnTransaction(tenantDb, fn) {
|
|
|
327
356
|
export function createTenantDb(options) {
|
|
328
357
|
return buildTenantDb(options);
|
|
329
358
|
}
|
|
359
|
+
// Tenant-destroy hooks purge their own tenant's event-fed rows; the pinned tenant is the only reach and unsafeRaw stays without the write capability.
|
|
360
|
+
export function createTenantDataDestroyDb(options) {
|
|
361
|
+
return createTenantDb({ ...options, db: withExecutorManagedWrites(options.db), mode: "tenant" });
|
|
362
|
+
}
|
|
330
363
|
// Untyped JS callers or an `as` cast can still hand in undefined; a scoped handle
|
|
331
364
|
// without a tenant would bind `tenant_id = NULL` instead of failing loudly.
|
|
332
365
|
function buildTenantDb(options) {
|
|
@@ -424,14 +457,6 @@ function buildTenantDb(options) {
|
|
|
424
457
|
"writes through db.global().",
|
|
425
458
|
});
|
|
426
459
|
}
|
|
427
|
-
// The ExecutorOnly brand is type-only; a type-erased table must still not reach the read model without an event.
|
|
428
|
-
function executorManagedWriteDenied(table) {
|
|
429
|
-
if (asEntityTableMeta(table)?.source === "unmanaged")
|
|
430
|
-
return undefined;
|
|
431
|
-
return new AccessDeniedError({
|
|
432
|
-
message: `db.global(${tableNameOf(table)}): writes on executor-managed entity tables must go through the entity executor`,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
460
|
function writesDenied(operation) {
|
|
436
461
|
const reason = grants?.writesDeniedReason;
|
|
437
462
|
return reason === undefined ? undefined : tenantDbWritesDeniedError(operation, reason);
|
|
@@ -468,6 +493,26 @@ function buildTenantDb(options) {
|
|
|
468
493
|
"use db.global(table) with escapeHatch instead.",
|
|
469
494
|
});
|
|
470
495
|
}
|
|
496
|
+
// Callers ran executorManagedWriteDenied first: either db holds the capability or the table is
|
|
497
|
+
// unmanaged. The split only proves to the compiler which overload of the free writers applies.
|
|
498
|
+
function insertRow(table, values) {
|
|
499
|
+
if (hasExecutorManagedWriteCapability(db))
|
|
500
|
+
return bunInsertOne(db, table, values);
|
|
501
|
+
assertWritableTable(table);
|
|
502
|
+
return bunInsertOne(db, table, values);
|
|
503
|
+
}
|
|
504
|
+
function updateRows(table, set, where) {
|
|
505
|
+
if (hasExecutorManagedWriteCapability(db))
|
|
506
|
+
return bunUpdateMany(db, table, set, where);
|
|
507
|
+
assertWritableTable(table);
|
|
508
|
+
return bunUpdateMany(db, table, set, where);
|
|
509
|
+
}
|
|
510
|
+
function deleteRows(table, where) {
|
|
511
|
+
if (hasExecutorManagedWriteCapability(db))
|
|
512
|
+
return bunDeleteMany(db, table, where);
|
|
513
|
+
assertWritableTable(table);
|
|
514
|
+
return bunDeleteMany(db, table, where);
|
|
515
|
+
}
|
|
471
516
|
function globalTable(table) {
|
|
472
517
|
const meta = asEntityTableMeta(table);
|
|
473
518
|
if (meta?.tenancy !== "global") {
|
|
@@ -485,18 +530,18 @@ function buildTenantDb(options) {
|
|
|
485
530
|
insertOne(values) {
|
|
486
531
|
const denied = writesDenied("ctx.db.global().insertOne") ??
|
|
487
532
|
missingEscapeHatch(table) ??
|
|
488
|
-
executorManagedWriteDenied(table) ??
|
|
533
|
+
executorManagedWriteDenied(db, table, "db.global().insertOne") ??
|
|
489
534
|
foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
|
|
490
535
|
personalDataDenied(table, Object.keys(values));
|
|
491
536
|
if (denied)
|
|
492
537
|
return Promise.reject(denied);
|
|
493
538
|
report("global-write", globalWriteReason());
|
|
494
|
-
return withDbSpan("insert", table, async () =>
|
|
539
|
+
return withDbSpan("insert", table, async () => insertRow(table, values));
|
|
495
540
|
},
|
|
496
541
|
updateMany(set, where) {
|
|
497
542
|
const denied = writesDenied("ctx.db.global().updateMany") ??
|
|
498
543
|
missingEscapeHatch(table) ??
|
|
499
|
-
executorManagedWriteDenied(table) ??
|
|
544
|
+
executorManagedWriteDenied(db, table, "db.global().updateMany") ??
|
|
500
545
|
foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
|
|
501
546
|
personalDataDenied(table, Object.keys(set));
|
|
502
547
|
if (denied)
|
|
@@ -505,12 +550,12 @@ function buildTenantDb(options) {
|
|
|
505
550
|
return Promise.reject(new Error("db.global().updateMany without where would mass-update every tenant's rows. Pass at least one where condition."));
|
|
506
551
|
}
|
|
507
552
|
report("global-write", globalWriteReason());
|
|
508
|
-
return withDbSpan("update", table, async () =>
|
|
553
|
+
return withDbSpan("update", table, async () => updateRows(table, set, where));
|
|
509
554
|
},
|
|
510
555
|
deleteMany(where) {
|
|
511
556
|
const denied = writesDenied("ctx.db.global().deleteMany") ??
|
|
512
557
|
missingEscapeHatch(table) ??
|
|
513
|
-
executorManagedWriteDenied(table);
|
|
558
|
+
executorManagedWriteDenied(db, table, "db.global().deleteMany");
|
|
514
559
|
if (denied)
|
|
515
560
|
return Promise.reject(denied);
|
|
516
561
|
if (!where || Object.keys(where).length === 0) {
|
|
@@ -518,10 +563,10 @@ function buildTenantDb(options) {
|
|
|
518
563
|
}
|
|
519
564
|
report("global-write", globalWriteReason());
|
|
520
565
|
return withDbSpan("delete", table, async () => {
|
|
521
|
-
await
|
|
566
|
+
await deleteRows(table, where);
|
|
522
567
|
});
|
|
523
568
|
},
|
|
524
|
-
// @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable
|
|
569
|
+
// @cast-boundary phantom-brand — GlobalWrites is only present in the type when TTable carries Writable; the object above always carries the methods.
|
|
525
570
|
};
|
|
526
571
|
}
|
|
527
572
|
function grantedUnsafeRawRunner(declaredStepReason) {
|
|
@@ -537,7 +582,7 @@ function buildTenantDb(options) {
|
|
|
537
582
|
}
|
|
538
583
|
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
539
584
|
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
540
|
-
return boundRunner(db, personalDataGate, projectionRegistry);
|
|
585
|
+
return boundRunner(withoutExecutorManagedWrites(db), personalDataGate, projectionRegistry, false);
|
|
541
586
|
}
|
|
542
587
|
const tenantDb = {
|
|
543
588
|
tenantId,
|
|
@@ -559,6 +604,9 @@ function buildTenantDb(options) {
|
|
|
559
604
|
advisoryXactLock(namespace, key) {
|
|
560
605
|
return acquireNamespacedAdvisoryLock(db, namespace, `${tenantId}:${key}`);
|
|
561
606
|
},
|
|
607
|
+
databaseNow() {
|
|
608
|
+
return queryDatabaseNow(db);
|
|
609
|
+
},
|
|
562
610
|
aggregate(table, spec, where) {
|
|
563
611
|
const filter = readWhere(table, where);
|
|
564
612
|
return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
|
|
@@ -578,8 +626,11 @@ function buildTenantDb(options) {
|
|
|
578
626
|
const personalDenied = personalDataDenied(table, Object.keys(values));
|
|
579
627
|
if (personalDenied)
|
|
580
628
|
return Promise.reject(personalDenied);
|
|
629
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.insertOne");
|
|
630
|
+
if (managedDenied)
|
|
631
|
+
return Promise.reject(managedDenied);
|
|
581
632
|
const data = insertValues(table, values);
|
|
582
|
-
return withDbSpan("insert", table, async () =>
|
|
633
|
+
return withDbSpan("insert", table, async () => insertRow(table, data));
|
|
583
634
|
},
|
|
584
635
|
updateMany(table, set, where) {
|
|
585
636
|
const writeDenied = writesDenied("ctx.db.updateMany");
|
|
@@ -594,8 +645,11 @@ function buildTenantDb(options) {
|
|
|
594
645
|
const personalDenied = personalDataDenied(table, Object.keys(set));
|
|
595
646
|
if (personalDenied)
|
|
596
647
|
return Promise.reject(personalDenied);
|
|
648
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.updateMany");
|
|
649
|
+
if (managedDenied)
|
|
650
|
+
return Promise.reject(managedDenied);
|
|
597
651
|
const filter = writeWhere(table, where);
|
|
598
|
-
return withDbSpan("update", table, async () =>
|
|
652
|
+
return withDbSpan("update", table, async () => updateRows(table, set, filter));
|
|
599
653
|
},
|
|
600
654
|
deleteMany(table, where) {
|
|
601
655
|
const writeDenied = writesDenied("ctx.db.deleteMany");
|
|
@@ -607,8 +661,11 @@ function buildTenantDb(options) {
|
|
|
607
661
|
const globalDenied = tenantWriteOnGlobalTable(table, "deleteMany");
|
|
608
662
|
if (globalDenied)
|
|
609
663
|
return Promise.reject(globalDenied);
|
|
664
|
+
const managedDenied = executorManagedWriteDenied(db, table, "ctx.db.deleteMany");
|
|
665
|
+
if (managedDenied)
|
|
666
|
+
return Promise.reject(managedDenied);
|
|
610
667
|
const filter = writeWhere(table, where);
|
|
611
|
-
return withDbSpan("delete", table, async () =>
|
|
668
|
+
return withDbSpan("delete", table, async () => deleteRows(table, filter));
|
|
612
669
|
},
|
|
613
670
|
};
|
|
614
671
|
declaredUnsafeRawRunners.set(tenantDb, grantedUnsafeRawRunner);
|
|
@@ -659,10 +659,15 @@ function validateEmbeddedTotalsColumns(fieldName, entityName, field) {
|
|
|
659
659
|
// skip: no totals columns declared — nothing to validate
|
|
660
660
|
if (field.totals === undefined)
|
|
661
661
|
return;
|
|
662
|
-
for (const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
662
|
+
for (const total of field.totals) {
|
|
663
|
+
if (typeof total !== "string" && total.from.length === 0) {
|
|
664
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totals entry "${total.label}" with an empty "from". List at least one number, money or decimal sub-field.`);
|
|
665
|
+
}
|
|
666
|
+
for (const totalName of typeof total === "string" ? [total] : total.from) {
|
|
667
|
+
const totalSubField = field.schema[totalName];
|
|
668
|
+
if (!totalSubField || !NUMERIC_EMBEDDED_SUB_TYPES.has(totalSubField.type)) {
|
|
669
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field. List only number, money or decimal sub-fields in totals.`);
|
|
670
|
+
}
|
|
666
671
|
}
|
|
667
672
|
}
|
|
668
673
|
}
|
|
@@ -86,6 +86,14 @@ function checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, scre
|
|
|
86
86
|
checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
|
|
87
87
|
if (screen.type === "actionForm" && screen.load !== undefined) {
|
|
88
88
|
checkQueryRef(queryHandlers, screen.load.query, () => `[Feature ${featureName}] Screen "${screenId}" (actionForm) load`);
|
|
89
|
+
// A singleton load sends no id, so a query that requires input would fail on every open.
|
|
90
|
+
const loadHandler = queryHandlers.get(screen.load.query);
|
|
91
|
+
if (screen.load.singleton === true &&
|
|
92
|
+
loadHandler !== undefined &&
|
|
93
|
+
!loadHandler.schema.safeParse({}).success) {
|
|
94
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (actionForm) load.singleton query "${screen.load.query}" ` +
|
|
95
|
+
"requires input; a singleton load runs with an empty payload. Make the query's input fields optional.");
|
|
96
|
+
}
|
|
89
97
|
}
|
|
90
98
|
if (screen.type !== "entityEdit") {
|
|
91
99
|
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
|
|
@@ -183,21 +183,25 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
|
|
|
183
183
|
return;
|
|
184
184
|
if (target.screen.type === "dashboard") {
|
|
185
185
|
const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
186
|
-
const filter = target.screen
|
|
187
|
-
|
|
186
|
+
const { filter, record, timeRange } = target.screen;
|
|
187
|
+
const readIds = [filter?.id, record?.param, timeRange?.id].filter((id) => id !== undefined);
|
|
188
|
+
if (readIds.length === 0) {
|
|
188
189
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
189
190
|
`params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
|
|
190
|
-
`filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
|
|
191
|
+
`filter, time range or record binding — params would be a no-op. Remove the params extractor, or add a \`filter\` (or a \`record\` binding) to the ` +
|
|
191
192
|
`target dashboard so it has somewhere to read the value from.`);
|
|
192
193
|
}
|
|
193
194
|
const extractedKeys = rowFieldExtractorKeys(action.params);
|
|
194
|
-
if (!extractedKeys.includes(
|
|
195
|
+
if (!readIds.some((id) => extractedKeys.includes(id))) {
|
|
196
|
+
const readDescriptor = filter !== undefined && readIds.length === 1
|
|
197
|
+
? `filter id is "${filter.id}"`
|
|
198
|
+
: `URL params are [${readIds.join(", ")}]`;
|
|
195
199
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
196
200
|
`params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
|
|
197
|
-
|
|
198
|
-
`unset. Fix the params extractor to produce
|
|
201
|
+
`${readDescriptor} — none of the extracted keys match, so the value would stay ` +
|
|
202
|
+
`unset. Fix the params extractor to produce one of these keys, or remove it.`);
|
|
199
203
|
}
|
|
200
|
-
// skip:
|
|
204
|
+
// skip: a read id is present and the extractor's keys cover it — valid dashboard deep-link.
|
|
201
205
|
return;
|
|
202
206
|
}
|
|
203
207
|
if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
|
|
@@ -950,6 +954,13 @@ function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandl
|
|
|
950
954
|
validateFormDerivations(feature.name, screenId, screen);
|
|
951
955
|
validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
|
|
952
956
|
}
|
|
957
|
+
if (screen.type === "actionForm" &&
|
|
958
|
+
screen.load?.singleton === true &&
|
|
959
|
+
screen.load.idParam !== undefined) {
|
|
960
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both load.singleton: true ` +
|
|
961
|
+
`and load.idParam — singleton means the query picks the row from the caller's context, so ` +
|
|
962
|
+
`there is no record id to send under idParam. Remove idParam.`);
|
|
963
|
+
}
|
|
953
964
|
validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
|
|
954
965
|
}
|
|
955
966
|
// SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
|
|
@@ -2079,7 +2090,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
|
|
|
2079
2090
|
validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
|
|
2080
2091
|
}
|
|
2081
2092
|
else if (panel.kind === "text") {
|
|
2082
|
-
validateDashboardTextPanel(featureName, screenId, panel);
|
|
2093
|
+
validateDashboardTextPanel(featureName, screenId, panel, featureMap);
|
|
2083
2094
|
}
|
|
2084
2095
|
else {
|
|
2085
2096
|
validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
|
|
@@ -2263,7 +2274,7 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
|
|
|
2263
2274
|
throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
2264
2275
|
}
|
|
2265
2276
|
}
|
|
2266
|
-
function validateDashboardTextPanel(featureName, screenId, panel) {
|
|
2277
|
+
function validateDashboardTextPanel(featureName, screenId, panel, featureMap) {
|
|
2267
2278
|
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
|
|
2268
2279
|
if (panel.blocks.length === 0) {
|
|
2269
2280
|
throw new Error(`${context} has an empty blocks list. Add at least one block.`);
|
|
@@ -2274,6 +2285,20 @@ function validateDashboardTextPanel(featureName, screenId, panel) {
|
|
|
2274
2285
|
if (block.kind === "list" && block.items.length === 0) {
|
|
2275
2286
|
throw new Error(`${context} has a list block without items. Add items or remove the block.`);
|
|
2276
2287
|
}
|
|
2288
|
+
if (block.kind === "link")
|
|
2289
|
+
validateDashboardTextLinkBlock(context, block, featureName, featureMap);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
function validateDashboardTextLinkBlock(context, block, featureName, featureMap) {
|
|
2293
|
+
const targetQn = resolveScreenTargetQn(featureName, block.screen);
|
|
2294
|
+
const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
|
|
2295
|
+
if (featureMap.get(targetFeatureName)?.screens[targetShortId] === undefined) {
|
|
2296
|
+
throw new Error(`${context} link block screen "${block.screen}" does not resolve to a registered screen ` +
|
|
2297
|
+
`(checked "${targetQn}"). Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
|
|
2298
|
+
}
|
|
2299
|
+
if (block.roles?.length === 0) {
|
|
2300
|
+
throw new Error(`${context} link block "${block.screen}" has an empty roles list, which would hide it from everyone. ` +
|
|
2301
|
+
`List the roles or omit roles.`);
|
|
2277
2302
|
}
|
|
2278
2303
|
}
|
|
2279
2304
|
function validateDashboardPanelVisibleWhen(context, visibleWhen) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ExecutorManagedTenantDb } from "../../db/tenant-db.ts";
|
|
3
3
|
import type { Registry, TenantId } from "../types/index.ts";
|
|
4
4
|
import type { TenantDestroyHookResult } from "./tenant-resource.ts";
|
|
5
5
|
export interface TenantDataHookCtx {
|
|
6
|
-
readonly db:
|
|
6
|
+
readonly db: ExecutorManagedTenantDb;
|
|
7
7
|
readonly registry: Registry;
|
|
8
8
|
readonly tenantId: TenantId;
|
|
9
9
|
readonly deadlineAt?: number;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
3
3
|
import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_ROUTE_PATH_PREFIX, } from "@cosmicdrift/kumiko-types/websocket-route";
|
|
4
|
+
import { markExecutorManagedTable } from "../db/executor-managed-tables.js";
|
|
4
5
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
|
|
5
6
|
import { LifecycleHookTypes } from "./constants.js";
|
|
6
7
|
import { resolveName } from "./handler-helpers.js";
|
|
@@ -298,6 +299,9 @@ export function buildUiExtensionsMethods(state, name) {
|
|
|
298
299
|
throw new Error(`[Feature ${name}] Projection "${definition.name}" already registered. ` +
|
|
299
300
|
`Projection names must be unique per feature.`);
|
|
300
301
|
}
|
|
302
|
+
if (typeof definition.table === "object" && definition.table !== null) {
|
|
303
|
+
markExecutorManagedTable(definition.table);
|
|
304
|
+
}
|
|
301
305
|
state.projections[definition.name] = definition;
|
|
302
306
|
},
|
|
303
307
|
multiStreamProjection(definition) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExecutorManagedRunner } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
2
2
|
import type { StoredEvent } from "../event-store/event-store.ts";
|
|
3
3
|
import type { MultiStreamApplyContext } from "../pipeline/multi-stream-apply-context.ts";
|
|
4
4
|
import type { MultiStreamApplyFn, ProjectionTable, SingleStreamApplyFn } from "./types/projection.ts";
|
|
5
|
-
export declare function defineApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx:
|
|
6
|
-
export declare function defineMspApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx:
|
|
5
|
+
export declare function defineApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, table: ProjectionTable) => Promise<void>): SingleStreamApplyFn;
|
|
6
|
+
export declare function defineMspApply<TPayload = Record<string, unknown>>(fn: (event: StoredEvent<TPayload>, tx: ExecutorManagedRunner, ctx: MultiStreamApplyContext) => Promise<void>): MultiStreamApplyFn;
|
|
7
7
|
export declare function setFields(table: ProjectionTable, fields: Record<string, unknown> | ((event: Parameters<SingleStreamApplyFn>[0]) => Record<string, unknown>)): SingleStreamApplyFn;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyEntityEvent } from "../db/apply-entity-event.js";
|
|
2
2
|
import { assertBackingTableSuperset, deriveEntityTableMeta } from "../db/entity-table-meta.js";
|
|
3
|
+
import { markExecutorManagedTable } from "../db/executor-managed-tables.js";
|
|
3
4
|
import { asEntityTableMeta } from "../db/query.js";
|
|
4
5
|
import { buildEntityTable } from "../db/table-builder.js";
|
|
5
6
|
import { qualifyEntityName } from "./qualified-name.js";
|
|
@@ -20,6 +21,7 @@ export function buildImplicitProjection(featureName, entityName, entity, qualify
|
|
|
20
21
|
const drizzleTable = backingTable !== undefined
|
|
21
22
|
? resolveBackingTable(entityName, entity, backingTable)
|
|
22
23
|
: buildEntityTable(entityName, entity);
|
|
24
|
+
markExecutorManagedTable(drizzleTable);
|
|
23
25
|
// applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
|
|
24
26
|
// Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
|
|
25
27
|
const handler = async (event, tx) => {
|
|
@@ -476,8 +476,11 @@ export function validateProjectionApplyKeys(state) {
|
|
|
476
476
|
// or `jobRun` (BullMQ-callback-driven lifecycle, no executor).
|
|
477
477
|
// A "Shape-Anchor"-entity is no longer needed for this case.
|
|
478
478
|
const isEventsOnlySource = !sources.every((src) => state.entityMap.has(src));
|
|
479
|
+
// An extension source may be a stream whose events the executor writes under a
|
|
480
|
+
// name that is no registered entity (document-extract); its auto-verbs count.
|
|
481
|
+
const extensionSources = projDef.extraSources ?? [];
|
|
479
482
|
for (const src of rebuildSources) {
|
|
480
|
-
if (state.entityMap.has(src)) {
|
|
483
|
+
if (state.entityMap.has(src) || extensionSources.includes(src)) {
|
|
481
484
|
for (const verb of ENTITY_LIFECYCLE_VERBS)
|
|
482
485
|
validEventTypes.add(`${src}.${verb}`);
|
|
483
486
|
}
|
|
@@ -236,12 +236,26 @@ export function fieldToZod(field, currencies, opts = {}) {
|
|
|
236
236
|
// whether the key may be omitted at all is decided by buildInsertSchema
|
|
237
237
|
// off the same flag. `minItems` overrides that default when set.
|
|
238
238
|
let list = z.array(row);
|
|
239
|
-
const min = field.minItems ?? (field.required === true ? 1 : undefined);
|
|
240
|
-
if (min !== undefined)
|
|
241
|
-
list = list.min(min);
|
|
242
239
|
if (field.maxItems !== undefined)
|
|
243
240
|
list = list.max(field.maxItems);
|
|
244
|
-
|
|
241
|
+
const min = field.minItems ?? (field.required === true ? 1 : undefined);
|
|
242
|
+
if (min === undefined)
|
|
243
|
+
return list;
|
|
244
|
+
// A custom issue rather than min(): other arrays (multiSelect) share zod's
|
|
245
|
+
// too_small and must not read "rows"; the bridge honours params.i18nKey.
|
|
246
|
+
return list.superRefine((rows, ctx) => {
|
|
247
|
+
// skip: the minimum is met, nothing to report
|
|
248
|
+
if (rows.length >= min)
|
|
249
|
+
return;
|
|
250
|
+
ctx.addIssue({
|
|
251
|
+
code: "custom",
|
|
252
|
+
input: rows,
|
|
253
|
+
params: {
|
|
254
|
+
i18nKey: `errors.validation.list_min_rows.${min === 1 ? "one" : "other"}`,
|
|
255
|
+
minimum: min,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
});
|
|
245
259
|
}
|
|
246
260
|
case "jsonb": {
|
|
247
261
|
// Free-form jsonb — keys sind tenant-/runtime-defined. Validation
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FieldDefinition, SelectFieldDef } from "./types/fields.ts";
|
|
2
2
|
import type { ActionFormScreenDefinition, ConfigEditScreenDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, FieldCondition, FormatSpec, ListColumnSpec, ProjectionDetailScreenDefinition, ScreenDefinition, SecretMintScreenDefinition } from "./types/screen.ts";
|
|
3
|
+
export declare const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
|
|
3
4
|
export declare function explicitListScreenId(screen: ScreenDefinition): string | undefined;
|
|
4
5
|
export declare function resolveNavParentScreen(screens: readonly ScreenDefinition[], detail: ScreenDefinition, getId: (screen: ScreenDefinition) => string): ScreenDefinition | undefined;
|
|
5
6
|
export type EditLayoutScreen = ProjectionDetailScreenDefinition | EntityEditScreenDefinition | ActionFormScreenDefinition | ConfigEditScreenDefinition | SecretMintScreenDefinition;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Entity name the renderer gives the synthesized entity of an actionForm, secretMint or confirm
|
|
2
|
+
// step. It is not registered, so nothing server-side may treat it as a real entity.
|
|
3
|
+
export const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
|
|
1
4
|
// Every screen type carrying `listScreenId` declares it identically; centralizing
|
|
2
5
|
// here keeps the switch the only place that grows for a new screen type.
|
|
3
6
|
export function explicitListScreenId(screen) {
|
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
// true buys an entity restore + trash (ctx.includeDeleted) + this cleanup, with
|
|
6
6
|
// no feature to wire.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
import { asEntityTableMeta,
|
|
8
|
+
// Each expired row is purged through the executor's forget verb: the `<entity>.forgotten`
|
|
9
|
+
// event replays on a projection rebuild, so the purged row stays gone. The earlier events stay
|
|
10
|
+
// in the log; irreversible event-log purging is data-retention's job (pruneEvents), a separate,
|
|
11
|
+
// consumer-lag-guarded path.
|
|
12
|
+
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
13
|
+
import { asEntityTableMeta, selectMany } from "../db/query.js";
|
|
14
14
|
import { hasFieldProperty } from "../db/table-introspection.js";
|
|
15
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
15
16
|
import { purgeExpiredFileRefsWithBytes } from "../files/purge-expired-file-ref-bytes.js";
|
|
16
17
|
import { FILE_REF_ENTITY_NAME } from "../files/types.js";
|
|
17
|
-
import { SYSTEM_USER_ID } from "./system-user.js";
|
|
18
|
+
import { createSystemUser, SYSTEM_USER_ID } from "./system-user.js";
|
|
19
|
+
import { SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
18
20
|
// qualifyEntityName convention (feature:type:kebab-name) with a reserved
|
|
19
21
|
// "soft-delete" owner — no real feature owns these; the framework synthesizes
|
|
20
22
|
// them. The job-runner keys cron scheduling off the name, the config-resolver
|
|
@@ -46,6 +48,10 @@ export function isSystemScopedSoftDeleteTable(table, entity) {
|
|
|
46
48
|
function isGlobalTenancyTable(table) {
|
|
47
49
|
return asEntityTableMeta(table)?.tenancy === "global";
|
|
48
50
|
}
|
|
51
|
+
async function resolveGraceDays(ctx, tenantId, db) {
|
|
52
|
+
const resolved = await ctx.configResolver?.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db);
|
|
53
|
+
return typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
|
|
54
|
+
}
|
|
49
55
|
export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
50
56
|
const { db, registry } = ctx;
|
|
51
57
|
if (!db || !registry) {
|
|
@@ -61,10 +67,7 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
|
61
67
|
// skip: cron fired without a perTenant fan-out tenant — nothing scoped to purge
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
64
|
-
const
|
|
65
|
-
? await ctx.configResolver.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db)
|
|
66
|
-
: undefined;
|
|
67
|
-
const graceDays = typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
|
|
70
|
+
const graceDays = await resolveGraceDays(ctx, tenantId, db);
|
|
68
71
|
const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
|
|
69
72
|
for (const proj of registry.getAllProjections().values()) {
|
|
70
73
|
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
|
|
@@ -80,23 +83,49 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
|
80
83
|
if (isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
81
84
|
continue;
|
|
82
85
|
const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
|
|
83
|
-
|
|
86
|
+
const executor = createEventStoreExecutor(proj.table, entity, { entityName: proj.source });
|
|
87
|
+
await purgeExpiredRows({ db, executor, user: ctx.systemUser ?? createSystemUser(tenantId) }, proj.source, proj.table, where, cutoff, ctx);
|
|
84
88
|
}
|
|
85
89
|
};
|
|
86
|
-
|
|
90
|
+
const PURGE_PAGE_SIZE = 500;
|
|
91
|
+
async function forgetRow(writer, id) {
|
|
92
|
+
// ponytail: one append per expired row, batch when purge volume needs it
|
|
93
|
+
const result = await writer.executor.forget({ id }, writer.user, writer.db);
|
|
94
|
+
if (!result.isSuccess)
|
|
95
|
+
throw reraiseAsKumikoError(result.error);
|
|
96
|
+
}
|
|
97
|
+
async function purgeExpiredRows(writer, source, table, where, cutoff, ctx, readDb = writer.db) {
|
|
87
98
|
if (source === FILE_REF_ENTITY_NAME) {
|
|
88
99
|
// The row is the only record of the storageKey, so its bytes go first.
|
|
89
|
-
await purgeExpiredFileRefsWithBytes({
|
|
90
|
-
db,
|
|
100
|
+
return await purgeExpiredFileRefsWithBytes({
|
|
101
|
+
db: writer.db,
|
|
91
102
|
table,
|
|
92
103
|
where,
|
|
93
104
|
expiredBefore: cutoff,
|
|
94
105
|
files: ctx.files,
|
|
95
106
|
log: ctx.log,
|
|
107
|
+
forgetRow: (id) => forgetRow(writer, id),
|
|
96
108
|
});
|
|
97
109
|
}
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
await forgetExpiredPages(writer, source, table, where, ctx, readDb);
|
|
111
|
+
}
|
|
112
|
+
// Keyset on id: a row whose forget fails is logged and skipped, it must not hide the rows behind it.
|
|
113
|
+
async function forgetExpiredPages(writer, source, table, where, ctx, readDb) {
|
|
114
|
+
let afterId;
|
|
115
|
+
for (;;) {
|
|
116
|
+
const page = await selectMany(readDb, table, { ...where, ...(afterId === undefined ? {} : { id: { gt: afterId } }) }, { orderBy: { col: "id", direction: "asc" }, limit: PURGE_PAGE_SIZE });
|
|
117
|
+
const last = page[page.length - 1];
|
|
118
|
+
if (last === undefined)
|
|
119
|
+
break;
|
|
120
|
+
for (const row of page) {
|
|
121
|
+
try {
|
|
122
|
+
await forgetRow(writer, row.id);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
ctx.log?.warn(`[soft-delete-cleanup] ${source} ${row.id} not purged: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
afterId = last.id;
|
|
100
129
|
}
|
|
101
130
|
}
|
|
102
131
|
// System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
|
|
@@ -119,13 +148,13 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
|
|
|
119
148
|
if (!isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
120
149
|
continue;
|
|
121
150
|
const where = { isDeleted: true, deletedAt: { lt: cutoff } };
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
const writer = {
|
|
152
|
+
db,
|
|
153
|
+
executor: createEventStoreExecutor(proj.table, entity, { entityName: proj.source }),
|
|
154
|
+
user: ctx.systemUser ?? createSystemUser(SYSTEM_TENANT_ID),
|
|
155
|
+
};
|
|
156
|
+
// A global-tenancy table is invisible to a tenant-mode ctx.db, so its candidates are read unscoped.
|
|
157
|
+
await purgeExpiredRows(writer, proj.source, proj.table, where, cutoff, ctx, isGlobalTenancyTable(proj.table) ? db.unsafeRaw() : db);
|
|
129
158
|
}
|
|
130
159
|
};
|
|
131
160
|
export function buildSoftDeleteCleanupJob() {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// must commit in the same TX as the aggregate-mutation that triggered
|
|
17
17
|
// it (stronger consistency than an async projection). Reviewer judges.
|
|
18
18
|
import { deleteMany } from "../../db/query.js";
|
|
19
|
+
import { requireEntityTableMeta } from "../../db/table-introspection.js";
|
|
20
|
+
import { withExecutorManagedWrites } from "../../db/tenant-db.js";
|
|
19
21
|
import { tenantDbWriteRunner } from "../../db/tenant-db-runner.js";
|
|
20
22
|
import { defineStep } from "../define-step.js";
|
|
21
23
|
import { resolveRequired } from "./_resolver-utils.js";
|
|
@@ -26,8 +28,8 @@ defineStep({
|
|
|
26
28
|
const where = resolveRequired(args.where, ctx);
|
|
27
29
|
// The `unsafe*`-step IS the sanctioned direct projection write — it exists
|
|
28
30
|
// to bypass the executor deliberately (boot-validated allowlist + reviewer
|
|
29
|
-
// gate).
|
|
30
|
-
await deleteMany(tenantDbWriteRunner(ctx.db, "unsafe projection delete"), args.table, where);
|
|
31
|
+
// gate).
|
|
32
|
+
await deleteMany(withExecutorManagedWrites(tenantDbWriteRunner(ctx.db, "unsafe projection delete")), requireEntityTableMeta(args.table, "unsafeProjectionDelete"), where);
|
|
31
33
|
},
|
|
32
34
|
});
|
|
33
35
|
export function buildUnsafeProjectionDeleteStep(args) {
|