@cosmicdrift/kumiko-framework 0.341.0 → 0.343.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/dist/api/auth-routes.d.ts +2 -0
- package/dist/api/auth-routes.js +35 -2
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-id-middleware.d.ts +2 -0
- package/dist/api/request-id-middleware.js +4 -1
- package/dist/api/routes.js +5 -5
- package/dist/api/server.d.ts +3 -0
- package/dist/api/server.js +4 -1
- package/dist/compliance/duration-spec.d.ts +2 -1
- package/dist/compliance/duration-spec.js +11 -0
- package/dist/compliance/index.d.ts +2 -2
- package/dist/compliance/index.js +1 -1
- package/dist/compliance/profiles.d.ts +6 -5
- package/dist/crypto/kek-source.d.ts +3 -4
- package/dist/crypto/kek-source.js +2 -5
- package/dist/crypto/kms-wiring.d.ts +2 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/db/entity-field-encryption.d.ts +1 -1
- package/dist/db/entity-field-encryption.js +5 -5
- package/dist/db/event-store-executor-write.js +38 -11
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/migrate-generator.d.ts +6 -0
- package/dist/db/migrate-generator.js +15 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +11 -1
- package/dist/db/projection-registry-binding.d.ts +3 -0
- package/dist/db/projection-registry-binding.js +12 -0
- package/dist/db/retired-framework-tables.d.ts +10 -0
- package/dist/db/retired-framework-tables.js +72 -0
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +22 -7
- package/dist/engine/boot-validator/index.d.ts +11 -0
- package/dist/engine/boot-validator/index.js +45 -1
- package/dist/engine/boot-validator/query-output-columns.js +8 -7
- package/dist/engine/boot-validator/query-refs.js +3 -0
- package/dist/engine/boot-validator/screens.js +28 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/soft-delete-cleanup.d.ts +2 -0
- package/dist/engine/soft-delete-cleanup.js +28 -9
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/env/index.d.ts +3 -0
- package/dist/env/index.js +12 -0
- package/dist/errors/docs-url.d.ts +7 -0
- package/dist/errors/docs-url.js +36 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +1 -0
- package/dist/errors/kumiko-error.d.ts +0 -2
- package/dist/errors/kumiko-error.js +0 -24
- package/dist/errors/serialize.d.ts +3 -2
- package/dist/errors/serialize.js +4 -2
- package/dist/jobs/job-runner.js +37 -2
- package/dist/observability/escape-hatch-report.d.ts +10 -1
- package/dist/observability/escape-hatch-report.js +19 -1
- package/dist/observability/index.d.ts +2 -2
- package/dist/observability/index.js +2 -2
- package/dist/observability/standard-metrics.d.ts +1 -0
- package/dist/observability/standard-metrics.js +14 -0
- package/dist/pipeline/dispatch-shared.js +3 -1
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +6 -0
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/projections-runner.d.ts +1 -0
- package/dist/pipeline/projections-runner.js +23 -14
- package/dist/rate-limit/middleware.js +1 -1
- package/dist/schema-cli.d.ts +8 -6
- package/dist/schema-cli.js +37 -4
- package/dist/stack/test-stack.d.ts +7 -0
- package/dist/stack/test-stack.js +6 -0
- package/package.json +4 -4
- package/src/changes.json +60 -0
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// fehl mit klarer Fehlermeldung (production-DBs haben das alte SQL drin —
|
|
15
15
|
// edit nachträglich = production-state inkonsistent mit committed-state).
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
// Adapter: extract raw postgres-js client from drizzle DbConnection,
|
|
20
20
|
// or use Bun.sql instance directly. Either way `.unsafe()` is the
|
|
@@ -171,6 +171,16 @@ export function loadMigrationsFromDir(dir, preprocess) {
|
|
|
171
171
|
};
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
|
+
// Raw file texts in migration order, comments intact (loadMigrationsFromDir
|
|
175
|
+
// strips them) — the retired-table scan reads them for DESTRUCTIVE markers.
|
|
176
|
+
export function readMigrationSqlTexts(dir) {
|
|
177
|
+
if (!existsSync(dir))
|
|
178
|
+
return [];
|
|
179
|
+
return readdirSync(dir)
|
|
180
|
+
.filter((f) => f.endsWith(".sql"))
|
|
181
|
+
.sort()
|
|
182
|
+
.map((file) => readFileSync(join(dir, file), "utf8"));
|
|
183
|
+
}
|
|
174
184
|
// Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
|
|
175
185
|
async function executeRaw(db, sqlText) {
|
|
176
186
|
await rawClient(db).unsafe(sqlText);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// A WeakMap rather than a process-global registry: test files boot several
|
|
2
|
+
// stacks in one process, each with its own Registry. Keys are TenantDbs and
|
|
3
|
+
// per-grant runner proxies — never the shared pool or tx handle, which would
|
|
4
|
+
// leak one dispatcher's registry onto sibling TenantDbs.
|
|
5
|
+
const bindings = new WeakMap();
|
|
6
|
+
export function bindProjectionRegistry(target, registry) {
|
|
7
|
+
bindings.set(target, registry);
|
|
8
|
+
}
|
|
9
|
+
// WeakMap.get never reads a property off target — safe for fail-closed guard Proxies.
|
|
10
|
+
export function projectionRegistryOf(target) {
|
|
11
|
+
return bindings.get(target);
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Snapshot } from "./migrate-generator.js";
|
|
2
|
+
export type RetiredFrameworkTable = {
|
|
3
|
+
readonly tableName: string;
|
|
4
|
+
readonly sinceVersion: string;
|
|
5
|
+
readonly explanation: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const retiredFrameworkTables: readonly RetiredFrameworkTable[];
|
|
8
|
+
export declare function findRetiredTableDrops(priorMigrationsSql: readonly string[], nextSnapshot: Snapshot): readonly RetiredFrameworkTable[];
|
|
9
|
+
export declare function findCommentedDropTables(migrationsSql: readonly string[], snapshot: Snapshot | null): readonly string[];
|
|
10
|
+
export declare function isRetiredFrameworkTable(tableName: string): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Framework tables that a framework release stopped creating and whose data
|
|
2
|
+
// was explicitly declared dispensable. `kumiko schema generate` drops them
|
|
3
|
+
// for real (instead of the commented-out DESTRUCTIVE marker app tables get),
|
|
4
|
+
// because the framework — not the app — owns the decision that the data is
|
|
5
|
+
// gone. Without this, every app carried a stale table forever behind a
|
|
6
|
+
// commented drop nobody uncomments.
|
|
7
|
+
//
|
|
8
|
+
// Add an entry only when the release notes state the data is not preserved.
|
|
9
|
+
// A rename is NOT a retirement: the data lives on under the new name.
|
|
10
|
+
export const retiredFrameworkTables = [
|
|
11
|
+
{
|
|
12
|
+
tableName: "read_job_runs",
|
|
13
|
+
sinceVersion: "0.210.0",
|
|
14
|
+
explanation: "replaced by store_job_runs; the job run history was not migrated (operational data, not a system of record)",
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
const IDENT = `"?([^"\\s(;,.]+)"?`;
|
|
18
|
+
const CREATE_TABLE_RE = new RegExp(`^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?${IDENT}`, "i");
|
|
19
|
+
const DROP_TABLE_RE = new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i");
|
|
20
|
+
const COMMENTED_DROP_TABLE_RE = new RegExp(`^--\\s*DESTRUCTIVE:\\s*DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i");
|
|
21
|
+
// Last known state per table across all migrations, in order — so a
|
|
22
|
+
// DROP followed by a re-CREATE (managed-projection recreate) counts as
|
|
23
|
+
// "created", and a commented drop followed by an executable one as "dropped".
|
|
24
|
+
function scanTableLifecycle(sqlTexts) {
|
|
25
|
+
const states = new Map();
|
|
26
|
+
for (const text of sqlTexts) {
|
|
27
|
+
for (const rawLine of text.split("\n")) {
|
|
28
|
+
const line = rawLine.trim();
|
|
29
|
+
const commentedDrop = COMMENTED_DROP_TABLE_RE.exec(line)?.[1];
|
|
30
|
+
if (commentedDrop !== undefined) {
|
|
31
|
+
states.set(commentedDrop, "commented-drop");
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (line.startsWith("--"))
|
|
35
|
+
continue;
|
|
36
|
+
const created = CREATE_TABLE_RE.exec(line)?.[1];
|
|
37
|
+
if (created !== undefined) {
|
|
38
|
+
states.set(created, "created");
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const dropped = DROP_TABLE_RE.exec(line)?.[1];
|
|
42
|
+
if (dropped !== undefined)
|
|
43
|
+
states.set(dropped, "dropped");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return states;
|
|
47
|
+
}
|
|
48
|
+
function snapshotHasTable(snapshot, tableName) {
|
|
49
|
+
return snapshot?.tables.some((t) => t.tableName === tableName) ?? false;
|
|
50
|
+
}
|
|
51
|
+
// Retired tables the next migration must drop: a migration created them (or
|
|
52
|
+
// carries a commented drop), none dropped them for real, and the schema no
|
|
53
|
+
// longer declares them.
|
|
54
|
+
export function findRetiredTableDrops(priorMigrationsSql, nextSnapshot) {
|
|
55
|
+
const states = scanTableLifecycle(priorMigrationsSql);
|
|
56
|
+
return retiredFrameworkTables.filter((retired) => {
|
|
57
|
+
const state = states.get(retired.tableName);
|
|
58
|
+
return ((state === "created" || state === "commented-drop") &&
|
|
59
|
+
!snapshotHasTable(nextSnapshot, retired.tableName));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Tables a migration only carries as a commented DESTRUCTIVE drop although
|
|
63
|
+
// the snapshot no longer has them: if they still exist in the live DB, the
|
|
64
|
+
// drop was never made.
|
|
65
|
+
export function findCommentedDropTables(migrationsSql, snapshot) {
|
|
66
|
+
return [...scanTableLifecycle(migrationsSql)]
|
|
67
|
+
.filter(([tableName, state]) => state === "commented-drop" && !snapshotHasTable(snapshot, tableName))
|
|
68
|
+
.map(([tableName]) => tableName);
|
|
69
|
+
}
|
|
70
|
+
export function isRetiredFrameworkTable(tableName) {
|
|
71
|
+
return retiredFrameworkTables.some((retired) => retired.tableName === tableName);
|
|
72
|
+
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type TenantDb, type TenantDbMode, type UncheckedSystemDb } from "@cosmi
|
|
|
5
5
|
import { asRawClient } from "../db/query.js";
|
|
6
6
|
import type { EntityDefinition } from "../engine/types/fields.js";
|
|
7
7
|
import { type TenantId } from "../engine/types/identifiers.js";
|
|
8
|
+
import type { Registry } from "../engine/types/index.js";
|
|
8
9
|
import { type Meter, type Tracer } from "../observability/index.js";
|
|
9
10
|
import type { DbRunner } from "./connection.js";
|
|
10
11
|
type Table = SchemaTable;
|
|
@@ -23,6 +24,7 @@ export type TenantDbGrants = {
|
|
|
23
24
|
readonly report?: EscapeHatchReporter;
|
|
24
25
|
readonly memberReadOnly?: boolean;
|
|
25
26
|
readonly personalDataGate?: PersonalDataGate;
|
|
27
|
+
readonly projectionRegistry?: Registry;
|
|
26
28
|
};
|
|
27
29
|
export type PersonalDataGate = (tableName: string, keys: readonly string[], entity?: EntityDefinition) => void;
|
|
28
30
|
export declare function withUnsafeRawGrant(tenantDb: TenantDb, grant: EscapeHatchDeclaration | undefined): TenantDb;
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -4,6 +4,7 @@ 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 { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
|
|
7
8
|
import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
|
|
8
9
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
9
10
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
@@ -14,7 +15,7 @@ const personalDataGates = new WeakMap();
|
|
|
14
15
|
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate. Keyed by a
|
|
15
16
|
// per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
|
|
16
17
|
const runnerPersonalDataGates = new WeakMap();
|
|
17
|
-
function gatedRunner(runner, gate) {
|
|
18
|
+
function gatedRunner(runner, gate, projectionRegistry) {
|
|
18
19
|
const proxy = new Proxy(runner, {
|
|
19
20
|
// Tagged-template calls need the real driver object as `this`.
|
|
20
21
|
apply(target, _thisArg, args) {
|
|
@@ -33,7 +34,7 @@ function gatedRunner(runner, gate) {
|
|
|
33
34
|
}
|
|
34
35
|
const gatedArgs = [
|
|
35
36
|
...args.slice(0, -1),
|
|
36
|
-
(tx) => callback(gatedRunner(tx, gate)),
|
|
37
|
+
(tx) => callback(gatedRunner(tx, gate, projectionRegistry)),
|
|
37
38
|
];
|
|
38
39
|
return Reflect.apply(value, target, gatedArgs);
|
|
39
40
|
};
|
|
@@ -43,6 +44,8 @@ function gatedRunner(runner, gate) {
|
|
|
43
44
|
// @cast-boundary proxy-erasure — Proxy<object> re-tags as the wrapped DbRunner shape.
|
|
44
45
|
});
|
|
45
46
|
runnerPersonalDataGates.set(proxy, gate);
|
|
47
|
+
if (projectionRegistry)
|
|
48
|
+
bindProjectionRegistry(proxy, projectionRegistry);
|
|
46
49
|
return proxy;
|
|
47
50
|
}
|
|
48
51
|
// The executor passes its entity so the check does not depend on the table-name lookup.
|
|
@@ -119,7 +122,9 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
119
122
|
report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
|
|
120
123
|
const runner = tenantDbRunner(db);
|
|
121
124
|
const personalDataGate = personalDataGates.get(db);
|
|
122
|
-
return personalDataGate
|
|
125
|
+
return personalDataGate
|
|
126
|
+
? gatedRunner(runner, personalDataGate, projectionRegistryOf(db))
|
|
127
|
+
: runner;
|
|
123
128
|
}
|
|
124
129
|
const uncheckedSystemDb = {
|
|
125
130
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
@@ -272,6 +277,11 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
272
277
|
registerStandardMetrics(meter);
|
|
273
278
|
const report = grants?.report ?? fallbackEscapeHatchReporter(tenantId);
|
|
274
279
|
const personalDataGate = grants?.personalDataGate ?? runnerPersonalDataGates.get(db);
|
|
280
|
+
const projectionRegistry = grants?.projectionRegistry ?? projectionRegistryOf(db);
|
|
281
|
+
// Rebinders carry the resolved registry explicitly: a fresh tx handle is not tagged.
|
|
282
|
+
const rebindGrants = projectionRegistry && grants?.projectionRegistry === undefined
|
|
283
|
+
? { ...grants, projectionRegistry }
|
|
284
|
+
: grants;
|
|
275
285
|
function withDbSpan(operation, table, runner) {
|
|
276
286
|
signal?.throwIfAborted();
|
|
277
287
|
if (!tracer && !meter)
|
|
@@ -446,7 +456,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
446
456
|
}
|
|
447
457
|
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
448
458
|
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
449
|
-
return personalDataGate ? gatedRunner(db, personalDataGate) : db;
|
|
459
|
+
return personalDataGate ? gatedRunner(db, personalDataGate, projectionRegistry) : db;
|
|
450
460
|
}
|
|
451
461
|
const tenantDb = {
|
|
452
462
|
tenantId,
|
|
@@ -509,10 +519,13 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
509
519
|
},
|
|
510
520
|
};
|
|
511
521
|
declaredUnsafeRawRunners.set(tenantDb, grantedUnsafeRawRunner);
|
|
512
|
-
unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb(db, tenantId, mode, tracer, meter, signal, {
|
|
522
|
+
unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb(db, tenantId, mode, tracer, meter, signal, {
|
|
523
|
+
...rebindGrants,
|
|
524
|
+
unsafeRaw: grant,
|
|
525
|
+
}));
|
|
513
526
|
crossTenantRebinders.set(tenantDb, (reason) => {
|
|
514
527
|
report("acknowledge-cross-tenant", reason);
|
|
515
|
-
return createTenantDb(db, tenantId, "system", tracer, meter, signal,
|
|
528
|
+
return createTenantDb(db, tenantId, "system", tracer, meter, signal, rebindGrants);
|
|
516
529
|
});
|
|
517
530
|
ownTransactionRebinders.set(tenantDb, async (fn) => {
|
|
518
531
|
if (grants?.memberReadOnly) {
|
|
@@ -522,12 +535,14 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
522
535
|
});
|
|
523
536
|
}
|
|
524
537
|
// Carries a runner-bound gate (grants.personalDataGate unset, resolved via the fallback above) forward explicitly, since the fresh tx-handle isn't itself registered in runnerPersonalDataGates.
|
|
525
|
-
const txGrants = personalDataGate ? { ...
|
|
538
|
+
const txGrants = personalDataGate ? { ...rebindGrants, personalDataGate } : rebindGrants;
|
|
526
539
|
return runInNewTransaction(db, (tx) => fn(createTenantDb(tx, tenantId, mode, tracer, meter, signal, txGrants)));
|
|
527
540
|
});
|
|
528
541
|
bindTenantDbRunner(tenantDb, db);
|
|
529
542
|
if (personalDataGate)
|
|
530
543
|
personalDataGates.set(tenantDb, personalDataGate);
|
|
544
|
+
if (projectionRegistry)
|
|
545
|
+
bindProjectionRegistry(tenantDb, projectionRegistry);
|
|
531
546
|
return tenantDb;
|
|
532
547
|
}
|
|
533
548
|
export { asRawClient };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EnvelopeCipher } from "../../secrets/envelope-cipher.js";
|
|
1
2
|
import type { FeatureDefinition } from "../types/index.js";
|
|
2
3
|
export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
|
|
3
4
|
export { collectWriteHandlerQns } from "./nav.js";
|
|
@@ -26,7 +27,17 @@ export type ValidateBootOptions = {
|
|
|
26
27
|
* `process.env`; runners pass their resolved env so a ciphertext-only
|
|
27
28
|
* key (already decrypted at boot) is visible to the probe. */
|
|
28
29
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
/** The cipher the runner injects after validation (built from the app's
|
|
31
|
+
* `masterKey` or the env keyring). Satisfies the availability check for
|
|
32
|
+
* `encrypted: true` fields without an env KEK. */
|
|
33
|
+
readonly entityFieldCipher?: EnvelopeCipher;
|
|
29
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Subset-safe slice of validateBoot for test stacks: nav references (screen,
|
|
37
|
+
* parent, workspace, tree actions), workspaces and nav cycles. The checks that
|
|
38
|
+
* need the whole app composition (screens, ref entities) run only in validateBoot.
|
|
39
|
+
*/
|
|
40
|
+
export declare function validateNavBootSubset(rawFeatures: readonly FeatureDefinition[]): void;
|
|
30
41
|
/**
|
|
31
42
|
* Validates all feature configurations at boot time.
|
|
32
43
|
* Throws on the first error found — fail fast.
|
|
@@ -34,6 +34,50 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
|
|
|
34
34
|
export { collectWriteHandlerQns } from "./nav.js";
|
|
35
35
|
export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline.js";
|
|
36
36
|
export { MAX_TRANSFER_DEPTH } from "./transfer-graph.js";
|
|
37
|
+
// Test stacks mount feature subsets, so a reference into a feature that is not
|
|
38
|
+
// mounted cannot be judged: it counts as present. A reference into a mounted
|
|
39
|
+
// feature must still resolve.
|
|
40
|
+
function mountedPrefixOf(qn) {
|
|
41
|
+
return qn.split(":", 1)[0] ?? qn;
|
|
42
|
+
}
|
|
43
|
+
class PrefixTolerantSet extends Set {
|
|
44
|
+
mountedFeatures;
|
|
45
|
+
constructor(members, mountedFeatures) {
|
|
46
|
+
super(members);
|
|
47
|
+
this.mountedFeatures = mountedFeatures;
|
|
48
|
+
}
|
|
49
|
+
has(qn) {
|
|
50
|
+
return super.has(qn) || !this.mountedFeatures.has(mountedPrefixOf(qn));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
class PrefixTolerantMap extends Map {
|
|
54
|
+
mountedFeatures;
|
|
55
|
+
constructor(members, mountedFeatures) {
|
|
56
|
+
super(members);
|
|
57
|
+
this.mountedFeatures = mountedFeatures;
|
|
58
|
+
}
|
|
59
|
+
has(qn) {
|
|
60
|
+
return super.has(qn) || !this.mountedFeatures.has(mountedPrefixOf(qn));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Subset-safe slice of validateBoot for test stacks: nav references (screen,
|
|
65
|
+
* parent, workspace, tree actions), workspaces and nav cycles. The checks that
|
|
66
|
+
* need the whole app composition (screens, ref entities) run only in validateBoot.
|
|
67
|
+
*/
|
|
68
|
+
export function validateNavBootSubset(rawFeatures) {
|
|
69
|
+
const features = dedupeFeatures(rawFeatures);
|
|
70
|
+
const mounted = new Set(features.map((f) => f.name));
|
|
71
|
+
const allNavQns = collectNavQns(features);
|
|
72
|
+
const tolerantScreens = new PrefixTolerantSet(collectScreenQns(features), mounted);
|
|
73
|
+
const tolerantNavs = new PrefixTolerantMap(allNavQns, mounted);
|
|
74
|
+
const tolerantWorkspaces = new PrefixTolerantMap(collectWorkspaceQns(features), mounted);
|
|
75
|
+
for (const feature of features) {
|
|
76
|
+
validateNavs(feature, tolerantScreens, tolerantNavs, tolerantWorkspaces);
|
|
77
|
+
validateWorkspaces(feature, tolerantNavs);
|
|
78
|
+
}
|
|
79
|
+
validateNavCycles(allNavQns);
|
|
80
|
+
}
|
|
37
81
|
/**
|
|
38
82
|
* Validates all feature configurations at boot time.
|
|
39
83
|
* Throws on the first error found — fail fast.
|
|
@@ -187,7 +231,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
187
231
|
// catches malformed keys (wrong length, bad base64) at boot instead of
|
|
188
232
|
// on the first encrypted read in prod. An injected cipher (test seam,
|
|
189
233
|
// custom KMS provider) satisfies the requirement without env keys.
|
|
190
|
-
validateEntityFieldEncryptionAvailable(options?.env);
|
|
234
|
+
validateEntityFieldEncryptionAvailable(options?.env, options?.entityFieldCipher);
|
|
191
235
|
}
|
|
192
236
|
if (hasFileFields && !(options?.env ?? process.env)[FILE_STORAGE_PROVIDER_ENV]) {
|
|
193
237
|
throw new Error(`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`);
|
|
@@ -165,13 +165,13 @@ function checkDashboardStatPanelFields(queryHandlers, featureName, screenId, pan
|
|
|
165
165
|
checkPanelField("deltaToneField", panel.deltaToneField);
|
|
166
166
|
}
|
|
167
167
|
// visibleWhen reads a flat record like stat panels; a typo'd field would hide
|
|
168
|
-
// the panel forever without any message.
|
|
169
|
-
function
|
|
170
|
-
// skip:
|
|
171
|
-
if (
|
|
168
|
+
// the panel or screen forever without any message.
|
|
169
|
+
function checkVisibleWhenField(queryHandlers, visibleWhen, owner) {
|
|
170
|
+
// skip: always visible, no field to check
|
|
171
|
+
if (visibleWhen === undefined)
|
|
172
172
|
return;
|
|
173
|
-
const { query, field } =
|
|
174
|
-
checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () =>
|
|
173
|
+
const { query, field } = visibleWhen;
|
|
174
|
+
checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `${owner} visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema.`);
|
|
175
175
|
}
|
|
176
176
|
function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen) {
|
|
177
177
|
for (const panel of screen.panels) {
|
|
@@ -184,7 +184,7 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
else if (panel.kind === "screen") {
|
|
187
|
-
|
|
187
|
+
checkVisibleWhenField(queryHandlers, panel.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`);
|
|
188
188
|
}
|
|
189
189
|
else if (panel.kind === "list") {
|
|
190
190
|
const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
|
|
@@ -197,6 +197,7 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen) {
|
|
200
|
+
checkVisibleWhenField(queryHandlers, screen.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (${screen.type})`);
|
|
200
201
|
if (screen.type === "projectionList") {
|
|
201
202
|
checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen);
|
|
202
203
|
}
|
|
@@ -52,6 +52,9 @@ function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenTyp
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
55
|
+
if (screen.visibleWhen !== undefined) {
|
|
56
|
+
checkQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
|
|
57
|
+
}
|
|
55
58
|
if (screen.type === "projectionList") {
|
|
56
59
|
checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
|
|
57
60
|
}
|
|
@@ -636,6 +636,33 @@ function validateRecordActionRedirect(feature, screenId, screenKind, action, all
|
|
|
636
636
|
validateRedirectTarget(feature, screenId, screenKind, action.redirect, allScreenQns);
|
|
637
637
|
}
|
|
638
638
|
}
|
|
639
|
+
function validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap) {
|
|
640
|
+
const { visibleWhen, fallback } = screen;
|
|
641
|
+
const context = `[Feature ${feature.name}] Screen "${screenId}" (${screen.type})`;
|
|
642
|
+
if (visibleWhen === undefined && fallback !== undefined) {
|
|
643
|
+
throw new Error(`${context} declares fallback without visibleWhen.`);
|
|
644
|
+
}
|
|
645
|
+
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
646
|
+
throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
|
|
647
|
+
}
|
|
648
|
+
if (fallback !== undefined) {
|
|
649
|
+
validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function validateScreenFallback(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
|
|
653
|
+
const context = `[Feature ${feature.name}] Screen "${screenId}" (${screenType})`;
|
|
654
|
+
validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
|
|
655
|
+
const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
|
|
656
|
+
if (fallbackQn === qualifyEntityName(feature.name, "screen", screenId)) {
|
|
657
|
+
throw new Error(`${context} fallback points at the screen itself.`);
|
|
658
|
+
}
|
|
659
|
+
// A gated fallback could hide in turn and chain or cycle (A→B→A); one hop keeps the
|
|
660
|
+
// runtime gate trivially terminating.
|
|
661
|
+
const [fallbackFeatureName = "", , fallbackShortId = ""] = fallbackQn.split(":");
|
|
662
|
+
if (featureMap.get(fallbackFeatureName)?.screens[fallbackShortId]?.visibleWhen !== undefined) {
|
|
663
|
+
throw new Error(`${context} fallback "${fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
639
666
|
function validateInlineFormNavTargets(feature, screenId, screen, kind, allScreenQns) {
|
|
640
667
|
if (screen.redirect !== undefined) {
|
|
641
668
|
validateRedirectTarget(feature, screenId, kind, screen.redirect, allScreenQns);
|
|
@@ -869,6 +896,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
|
|
|
869
896
|
};
|
|
870
897
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
871
898
|
validateScreenHasNavArea(feature, screenId, screen, featureMap);
|
|
899
|
+
validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap);
|
|
872
900
|
if (screen.type === "custom") {
|
|
873
901
|
if (!screen.renderer.react && !screen.renderer.native) {
|
|
874
902
|
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" has type="custom" but the renderer ` +
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
|
74
74
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
75
75
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
76
76
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
77
|
-
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
78
|
-
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
77
|
+
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
78
|
+
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
79
79
|
export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
|
|
80
80
|
export { runValidation } from "./validation.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -68,5 +68,5 @@ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
|
|
|
68
68
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
69
69
|
export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
|
|
70
70
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
71
|
-
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
71
|
+
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
72
72
|
export { runValidation } from "./validation.js";
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ConfigKeyDefinition, JobDefinition, JobHandlerFn } from "./types/config.js";
|
|
2
|
+
import type { EntityDefinition } from "./types/index.js";
|
|
2
3
|
export declare const SOFT_DELETE_CLEANUP_JOB = "soft-delete:job:cleanup";
|
|
3
4
|
export declare const SOFT_DELETE_CLEANUP_SYSTEM_JOB = "soft-delete:job:cleanup-system";
|
|
4
5
|
export declare const SOFT_DELETE_GRACE_DAYS_KEY = "soft-delete:config:grace-days";
|
|
5
6
|
export declare const DEFAULT_GRACE_DAYS = 30;
|
|
6
7
|
export declare const softDeleteGraceDaysConfig: ConfigKeyDefinition;
|
|
8
|
+
export declare function isSystemScopedSoftDeleteTable(table: unknown, entity: Pick<EntityDefinition, "tenancy">): boolean;
|
|
7
9
|
export declare const softDeleteCleanupJob: JobHandlerFn;
|
|
8
10
|
export declare const softDeleteCleanupSystemJob: JobHandlerFn;
|
|
9
11
|
export declare function buildSoftDeleteCleanupJob(): JobDefinition;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// resurrect the row as isDeleted=true. That's acceptable: cleanup bounds LIVE
|
|
11
11
|
// table growth; irreversible event-log purging is data-retention's job
|
|
12
12
|
// (pruneEvents), a separate, consumer-lag-guarded path.
|
|
13
|
-
import { deleteMany } from "../db/query.js";
|
|
13
|
+
import { asEntityTableMeta, deleteMany } from "../db/query.js";
|
|
14
14
|
import { SYSTEM_USER_ID } from "./system-user.js";
|
|
15
15
|
// qualifyEntityName convention (feature:type:kebab-name) with a reserved
|
|
16
16
|
// "soft-delete" owner — no real feature owns these; the framework synthesizes
|
|
@@ -31,6 +31,18 @@ export const softDeleteGraceDaysConfig = {
|
|
|
31
31
|
// existing silent-below-bound / audited-crossing behavior.
|
|
32
32
|
bounds: { min: 1 },
|
|
33
33
|
};
|
|
34
|
+
function hasTenantIdColumn(table) {
|
|
35
|
+
return table["tenantId"] !== undefined;
|
|
36
|
+
}
|
|
37
|
+
// The entity declaration decides, not the column: a `tenancy: "global"` table
|
|
38
|
+
// (e.g. user) still carries a tenantId column (SYSTEM_TENANT_ID), yet no
|
|
39
|
+
// tenant-mode delete can ever match its rows.
|
|
40
|
+
export function isSystemScopedSoftDeleteTable(table, entity) {
|
|
41
|
+
return entity.tenancy === "global" || !hasTenantIdColumn(table);
|
|
42
|
+
}
|
|
43
|
+
function isGlobalTenancyTable(table) {
|
|
44
|
+
return asEntityTableMeta(table)?.tenancy === "global";
|
|
45
|
+
}
|
|
34
46
|
export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
35
47
|
const { db, registry } = ctx;
|
|
36
48
|
if (!db || !registry) {
|
|
@@ -57,21 +69,18 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
|
57
69
|
const entity = registry.getEntity(proj.source);
|
|
58
70
|
if (!entity?.softDelete)
|
|
59
71
|
continue;
|
|
60
|
-
//
|
|
61
|
-
// list() does on table["tenantId"] to decide tenant-scoping.
|
|
62
|
-
const hasTenantColumn = proj.table["tenantId"] !== undefined;
|
|
63
|
-
// System-global entities (no tenantId column, e.g. `user`) are NOT swept
|
|
72
|
+
// System-global entities (tenancy "global" or no tenantId column, e.g. `user`) are NOT swept
|
|
64
73
|
// here: this handler runs once PER TENANT, so a tenant-scoped grace value
|
|
65
74
|
// would purge every OTHER tenant's still-within-grace rows too (effective
|
|
66
75
|
// grace = min() across all tenants). softDeleteCleanupSystemJob handles
|
|
67
76
|
// these separately, once, with a fixed grace period.
|
|
68
|
-
if (
|
|
77
|
+
if (isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
69
78
|
continue;
|
|
70
79
|
const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
|
|
71
80
|
await deleteMany(db, proj.table, where);
|
|
72
81
|
}
|
|
73
82
|
};
|
|
74
|
-
// System-global soft-deleted entities (no tenantId column) can't take a
|
|
83
|
+
// System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
|
|
75
84
|
// per-tenant grace period — there's no "this tenant's view" of a row that
|
|
76
85
|
// isn't scoped to any tenant. Runs once (not perTenant) with a fixed grace
|
|
77
86
|
// period; a future system-scope config key could make this configurable
|
|
@@ -88,9 +97,15 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
|
|
|
88
97
|
const entity = registry.getEntity(proj.source);
|
|
89
98
|
if (!entity?.softDelete)
|
|
90
99
|
continue;
|
|
91
|
-
if (proj.table
|
|
100
|
+
if (!isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
92
101
|
continue;
|
|
93
|
-
|
|
102
|
+
const where = { isDeleted: true, deletedAt: { lt: cutoff } };
|
|
103
|
+
if (isGlobalTenancyTable(proj.table)) {
|
|
104
|
+
await db.global(proj.table).deleteMany(where);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
await deleteMany(db, proj.table, where);
|
|
108
|
+
}
|
|
94
109
|
}
|
|
95
110
|
};
|
|
96
111
|
export function buildSoftDeleteCleanupJob() {
|
|
@@ -110,5 +125,9 @@ export function buildSoftDeleteCleanupSystemJob() {
|
|
|
110
125
|
trigger: { cron: "15 3 * * *" },
|
|
111
126
|
concurrency: "skip",
|
|
112
127
|
runIn: "worker",
|
|
128
|
+
escapeHatch: {
|
|
129
|
+
reason: "hard-deletes expired soft-deleted rows of global entities (e.g. user) once, system-wide",
|
|
130
|
+
grants: ["globalWrites"],
|
|
131
|
+
},
|
|
113
132
|
};
|
|
114
133
|
}
|
|
@@ -7,8 +7,8 @@ export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsM
|
|
|
7
7
|
export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
|
|
8
8
|
export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
|
|
9
9
|
export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
|
|
10
|
-
export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
|
|
11
|
-
export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
10
|
+
export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
|
|
11
|
+
export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
12
12
|
export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
|
|
13
13
|
export type { HttpRouteDefinition, HttpRouteHandler, HttpRouteHandlerDeps, HttpRouteMethod, } from "@cosmicdrift/kumiko-types/http-route";
|
|
14
14
|
export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
|
|
2
2
|
// runtime helpers below that stay framework-side.
|
|
3
3
|
export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
|
|
4
|
-
export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
4
|
+
export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
5
5
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-types/identifiers";
|
|
6
6
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
7
7
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, isFileField } from "../field-helpers.js";
|
package/dist/env/index.d.ts
CHANGED
|
@@ -57,6 +57,9 @@ export type ComposedEnvSchema = {
|
|
|
57
57
|
* "app" from `extend`, or "framework-core" from `core`. */
|
|
58
58
|
readonly sources: Readonly<Record<string, string>>;
|
|
59
59
|
};
|
|
60
|
+
/** Makes the named env keys optional while keeping their `kms` meta. Keys
|
|
61
|
+
* missing from the schema shape are ignored. */
|
|
62
|
+
export declare function withOptionalEnvKeys(schema: z.ZodObject<z.ZodRawShape>, keys: readonly string[]): z.ZodObject<z.ZodRawShape>;
|
|
60
63
|
export declare function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvSchema;
|
|
61
64
|
export type EnvErrorKind = "missing" | "invalid";
|
|
62
65
|
export type EnvError = {
|
package/dist/env/index.js
CHANGED
|
@@ -123,6 +123,18 @@ function optionalKeepingKmsMeta(field) {
|
|
|
123
123
|
? optional.meta({ kumiko: readKumikoMeta(field) })
|
|
124
124
|
: optional;
|
|
125
125
|
}
|
|
126
|
+
/** Makes the named env keys optional while keeping their `kms` meta. Keys
|
|
127
|
+
* missing from the schema shape are ignored. */
|
|
128
|
+
export function withOptionalEnvKeys(schema, keys) {
|
|
129
|
+
const shape = zodShape(schema);
|
|
130
|
+
const relaxed = {};
|
|
131
|
+
for (const key of keys) {
|
|
132
|
+
const field = shape[key];
|
|
133
|
+
if (field)
|
|
134
|
+
relaxed[key] = optionalKeepingKmsMeta(field);
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(relaxed).length === 0 ? schema : schema.safeExtend(relaxed);
|
|
137
|
+
}
|
|
126
138
|
export function composeEnvSchema(options) {
|
|
127
139
|
const optionalSet = new Set(options.optionalFeatures ?? []);
|
|
128
140
|
const merged = {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { KumikoError } from "./kumiko-error.js";
|
|
2
|
+
/** Links an app's own error reasons to its own docs; without it only framework reasons carry a docsUrl. */
|
|
3
|
+
export type ErrorDocsConfig = {
|
|
4
|
+
readonly baseUrl: string;
|
|
5
|
+
readonly reasons?: readonly string[] | "all";
|
|
6
|
+
};
|
|
7
|
+
export declare function resolveErrorDocsUrl(err: KumikoError, errorDocs?: ErrorDocsConfig): string | undefined;
|