@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.
Files changed (74) hide show
  1. package/dist/api/auth-routes.d.ts +2 -0
  2. package/dist/api/auth-routes.js +35 -2
  3. package/dist/api/request-context.d.ts +2 -0
  4. package/dist/api/request-id-middleware.d.ts +2 -0
  5. package/dist/api/request-id-middleware.js +4 -1
  6. package/dist/api/routes.js +5 -5
  7. package/dist/api/server.d.ts +3 -0
  8. package/dist/api/server.js +4 -1
  9. package/dist/compliance/duration-spec.d.ts +2 -1
  10. package/dist/compliance/duration-spec.js +11 -0
  11. package/dist/compliance/index.d.ts +2 -2
  12. package/dist/compliance/index.js +1 -1
  13. package/dist/compliance/profiles.d.ts +6 -5
  14. package/dist/crypto/kek-source.d.ts +3 -4
  15. package/dist/crypto/kek-source.js +2 -5
  16. package/dist/crypto/kms-wiring.d.ts +2 -2
  17. package/dist/crypto/kms-wiring.js +2 -2
  18. package/dist/db/entity-field-encryption.d.ts +1 -1
  19. package/dist/db/entity-field-encryption.js +5 -5
  20. package/dist/db/event-store-executor-write.js +38 -11
  21. package/dist/db/index.d.ts +2 -1
  22. package/dist/db/index.js +2 -1
  23. package/dist/db/migrate-generator.d.ts +6 -0
  24. package/dist/db/migrate-generator.js +15 -1
  25. package/dist/db/migrate-runner.d.ts +1 -0
  26. package/dist/db/migrate-runner.js +11 -1
  27. package/dist/db/projection-registry-binding.d.ts +3 -0
  28. package/dist/db/projection-registry-binding.js +12 -0
  29. package/dist/db/retired-framework-tables.d.ts +10 -0
  30. package/dist/db/retired-framework-tables.js +72 -0
  31. package/dist/db/tenant-db.d.ts +2 -0
  32. package/dist/db/tenant-db.js +22 -7
  33. package/dist/engine/boot-validator/index.d.ts +11 -0
  34. package/dist/engine/boot-validator/index.js +45 -1
  35. package/dist/engine/boot-validator/query-output-columns.js +8 -7
  36. package/dist/engine/boot-validator/query-refs.js +3 -0
  37. package/dist/engine/boot-validator/screens.js +28 -0
  38. package/dist/engine/index.d.ts +2 -2
  39. package/dist/engine/index.js +1 -1
  40. package/dist/engine/soft-delete-cleanup.d.ts +2 -0
  41. package/dist/engine/soft-delete-cleanup.js +28 -9
  42. package/dist/engine/types/index.d.ts +2 -2
  43. package/dist/engine/types/index.js +1 -1
  44. package/dist/env/index.d.ts +3 -0
  45. package/dist/env/index.js +12 -0
  46. package/dist/errors/docs-url.d.ts +7 -0
  47. package/dist/errors/docs-url.js +36 -0
  48. package/dist/errors/index.d.ts +2 -0
  49. package/dist/errors/index.js +1 -0
  50. package/dist/errors/kumiko-error.d.ts +0 -2
  51. package/dist/errors/kumiko-error.js +0 -24
  52. package/dist/errors/serialize.d.ts +3 -2
  53. package/dist/errors/serialize.js +4 -2
  54. package/dist/jobs/job-runner.js +37 -2
  55. package/dist/observability/escape-hatch-report.d.ts +10 -1
  56. package/dist/observability/escape-hatch-report.js +19 -1
  57. package/dist/observability/index.d.ts +2 -2
  58. package/dist/observability/index.js +2 -2
  59. package/dist/observability/standard-metrics.d.ts +1 -0
  60. package/dist/observability/standard-metrics.js +14 -0
  61. package/dist/pipeline/dispatch-shared.js +3 -1
  62. package/dist/pipeline/event-retention.d.ts +1 -0
  63. package/dist/pipeline/event-retention.js +6 -0
  64. package/dist/pipeline/index.d.ts +2 -0
  65. package/dist/pipeline/index.js +1 -0
  66. package/dist/pipeline/projections-runner.d.ts +1 -0
  67. package/dist/pipeline/projections-runner.js +23 -14
  68. package/dist/rate-limit/middleware.js +1 -1
  69. package/dist/schema-cli.d.ts +8 -6
  70. package/dist/schema-cli.js +37 -4
  71. package/dist/stack/test-stack.d.ts +7 -0
  72. package/dist/stack/test-stack.js +6 -0
  73. package/package.json +4 -4
  74. 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,3 @@
1
+ import type { Registry } from "../engine/types/index.js";
2
+ export declare function bindProjectionRegistry(target: object, registry: Registry): void;
3
+ export declare function projectionRegistryOf(target: object): Registry | undefined;
@@ -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
+ }
@@ -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;
@@ -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 ? gatedRunner(runner, personalDataGate) : runner;
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, { ...grants, unsafeRaw: grant }));
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, grants);
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 ? { ...grants, personalDataGate } : grants;
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 checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel) {
170
- // skip: panel is always visible, no field to check
171
- if (panel.visibleWhen === undefined)
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 } = panel.visibleWhen;
174
- checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}" visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema.`);
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
- checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel);
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 ` +
@@ -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";
@@ -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
- // @cast-boundary column-presence probe — identical access the executor's
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 (!hasTenantColumn)
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["tenantId"] !== undefined)
100
+ if (!isSystemScopedSoftDeleteTable(proj.table, entity))
92
101
  continue;
93
- await deleteMany(db, proj.table, { isDeleted: true, deletedAt: { lt: cutoff } });
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";
@@ -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;