@cosmicdrift/kumiko-framework 0.378.0 → 0.380.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/compose-stacks.md +1 -2
  3. package/agents/examples/currencies-global.md +2 -3
  4. package/agents/examples/embedded.md +23 -37
  5. package/agents/examples/event-sourcing.md +6 -5
  6. package/agents/examples/legal-pages.md +5 -5
  7. package/agents/examples/reference-data.md +26 -21
  8. package/agents/examples/relations.md +2 -1
  9. package/agents/features.md +4 -4
  10. package/dist/api/entity-read-access.d.ts +2 -0
  11. package/dist/api/entity-read-access.js +27 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/server.js +10 -3
  15. package/dist/api/sse-route.d.ts +5 -0
  16. package/dist/api/sse-route.js +59 -9
  17. package/dist/db/apply-entity-event.d.ts +3 -2
  18. package/dist/db/apply-entity-event.js +1 -1
  19. package/dist/db/dialect.d.ts +2 -1
  20. package/dist/db/dialect.js +2 -2
  21. package/dist/db/entity-table-from-registry.js +0 -2
  22. package/dist/db/entity-table-meta.d.ts +5 -3
  23. package/dist/db/entity-table-meta.js +6 -15
  24. package/dist/db/event-store-executor-write.js +21 -16
  25. package/dist/db/executor-managed-tables.d.ts +8 -0
  26. package/dist/db/executor-managed-tables.js +48 -0
  27. package/dist/db/index.d.ts +6 -4
  28. package/dist/db/index.js +4 -2
  29. package/dist/db/money.d.ts +1 -1
  30. package/dist/db/queries/backfill-pii.js +22 -12
  31. package/dist/db/queries/database-now.d.ts +5 -0
  32. package/dist/db/queries/database-now.js +12 -0
  33. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  34. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  35. package/dist/db/query.d.ts +22 -11
  36. package/dist/db/query.js +41 -36
  37. package/dist/db/reference-data.d.ts +5 -4
  38. package/dist/db/reference-data.js +23 -21
  39. package/dist/db/table-builder.d.ts +2 -3
  40. package/dist/db/table-builder.js +2 -0
  41. package/dist/db/table-introspection.d.ts +2 -0
  42. package/dist/db/table-introspection.js +4 -0
  43. package/dist/db/tenant-db-runner.d.ts +2 -0
  44. package/dist/db/tenant-db-runner.js +7 -0
  45. package/dist/db/tenant-db.d.ts +9 -2
  46. package/dist/db/tenant-db.js +82 -25
  47. package/dist/engine/boot-validator/entity-handler.js +9 -4
  48. package/dist/engine/boot-validator/query-refs.js +8 -0
  49. package/dist/engine/boot-validator/screens.js +34 -9
  50. package/dist/engine/extensions/tenant-data.d.ts +2 -2
  51. package/dist/engine/feature-ui-extensions.js +4 -0
  52. package/dist/engine/projection-helpers.d.ts +3 -3
  53. package/dist/engine/registry-state.js +2 -0
  54. package/dist/engine/registry-validate.js +4 -1
  55. package/dist/engine/schema-builder.js +18 -4
  56. package/dist/engine/screen-helpers.d.ts +1 -0
  57. package/dist/engine/screen-helpers.js +3 -0
  58. package/dist/engine/soft-delete-cleanup.js +53 -24
  59. package/dist/engine/steps/unsafe-projection-delete.js +4 -2
  60. package/dist/entrypoint/index.d.ts +1 -3
  61. package/dist/errors/reasons.d.ts +1 -0
  62. package/dist/errors/reasons.js +4 -1
  63. package/dist/errors/serialize.d.ts +1 -0
  64. package/dist/errors/serialize.js +9 -5
  65. package/dist/errors/write-error-info.js +3 -2
  66. package/dist/errors/zod-bridge.js +7 -0
  67. package/dist/es-ops/operations-schema.d.ts +1 -1
  68. package/dist/event-store/archive.d.ts +1 -1
  69. package/dist/event-store/events-schema.d.ts +1 -1
  70. package/dist/event-store/index.d.ts +1 -0
  71. package/dist/event-store/index.js +1 -0
  72. package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
  73. package/dist/event-store/snapshot.d.ts +1 -1
  74. package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
  75. package/dist/files/action-form-upload.d.ts +16 -0
  76. package/dist/files/action-form-upload.js +45 -0
  77. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  78. package/dist/files/bind-pending-file-refs.js +44 -4
  79. package/dist/files/create-file-ref.d.ts +3 -0
  80. package/dist/files/create-file-ref.js +41 -13
  81. package/dist/files/file-routes.js +29 -2
  82. package/dist/files/index.d.ts +2 -1
  83. package/dist/files/index.js +1 -0
  84. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  85. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  86. package/dist/files/storage-tracking.d.ts +1 -1
  87. package/dist/files/upload-ledger.d.ts +2 -2
  88. package/dist/internal/testing.d.ts +7 -0
  89. package/dist/internal/testing.js +14 -1
  90. package/dist/migrations/pending-rebuilds.d.ts +1 -1
  91. package/dist/migrations/pii-event-backfill.d.ts +1 -1
  92. package/dist/migrations/pii-event-backfill.js +14 -7
  93. package/dist/observability/escape-hatch-report.d.ts +1 -1
  94. package/dist/observability/escape-hatch-report.js +14 -3
  95. package/dist/pipeline/cascade-handler.js +79 -10
  96. package/dist/pipeline/dispatch-shared.js +1 -9
  97. package/dist/pipeline/event-consumer-state.d.ts +1 -1
  98. package/dist/pipeline/msp-rebuild.js +5 -3
  99. package/dist/pipeline/projection-rebuild.js +4 -2
  100. package/dist/pipeline/projection-state.d.ts +1 -1
  101. package/dist/pipeline/projections-runner.js +2 -1
  102. package/dist/rate-limit/index.d.ts +1 -1
  103. package/dist/rate-limit/index.js +1 -1
  104. package/dist/rate-limit/middleware.d.ts +4 -0
  105. package/dist/rate-limit/middleware.js +5 -2
  106. package/dist/ui-types/index.d.ts +2 -2
  107. package/dist/ui-types/index.js +2 -2
  108. package/dist/ui-types/list-row-meta.d.ts +3 -0
  109. package/dist/ui-types/list-row-meta.js +12 -1
  110. package/kumiko-es-tables.json +6 -0
  111. package/package.json +4 -3
  112. package/src/changes.json +117 -0
  113. package/src/scripts/codemod/README.md +20 -0
  114. package/src/scripts/codemod/codemod-support.ts +1 -1
  115. package/src/scripts/codemod/feature-layout.ts +416 -0
  116. package/src/scripts/codemod/package-merge.ts +0 -11
@@ -7,7 +7,7 @@ export declare function fileRefStorageDelta(event: {
7
7
  readonly bytes: number;
8
8
  readonly files: number;
9
9
  } | null;
10
- export declare const tenantStorageUsageTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
10
+ export declare const tenantStorageUsageTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
11
11
  export declare const filesStorageTrackingFeature: import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
12
12
  readonly exports: void;
13
13
  };
@@ -1,10 +1,10 @@
1
+ import type { WritableTable } from "@cosmicdrift/kumiko-types/executor-brand";
1
2
  import type { FileProviderResolver, FileUploadLedger } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
2
3
  import type { FileStorageProvider } from "@cosmicdrift/kumiko-types/file-storage-provider-types";
3
4
  import type { TenantId } from "@cosmicdrift/kumiko-types/identifiers";
4
5
  import type { DbConnection } from "../db/connection.ts";
5
- import { type EntityTableMeta } from "../db/entity-table-meta.ts";
6
6
  import { type TenantDb } from "../db/tenant-db.ts";
7
- export declare const fileUploadLedgerTable: EntityTableMeta;
7
+ export declare const fileUploadLedgerTable: WritableTable;
8
8
  export declare function createFileUploadLedger(db: DbConnection | TenantDb, tenantId: TenantId): FileUploadLedger;
9
9
  export declare function withUploadLedger(resolver: (tenantId: TenantId) => Promise<FileStorageProvider>, db: DbConnection | TenantDb): FileProviderResolver;
10
10
  export declare function takeUploadLedgerEntry(txDb: TenantDb, storageKey: string): Promise<boolean>;
@@ -1,4 +1,11 @@
1
+ import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
2
+ import type { ExecutorManagedRunner } from "@cosmicdrift/kumiko-types/executor-brand";
3
+ import type { AnyDb } from "../db/query.ts";
4
+ import { type ExecutorManagedTenantDb, type TenantDb } from "../db/tenant-db.ts";
1
5
  export { resetBlindIndexKeyForTests } from "../crypto/blind-index.ts";
2
6
  export { resetEventPiiCatalogForTests } from "../crypto/event-pii.ts";
3
7
  export { resetPiiSubjectKmsForTests } from "../crypto/pii-field-encryption.ts";
4
8
  export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encryption.ts";
9
+ export declare function withExecutorManagedWritesForTests(db: TenantDb): ExecutorManagedTenantDb;
10
+ export declare function withExecutorManagedWritesForTests(db: DbRunner): ExecutorManagedRunner;
11
+ export declare function withExecutorManagedWritesForTests(db: AnyDb): ExecutorManagedTenantDb | ExecutorManagedRunner;
@@ -1,5 +1,18 @@
1
- // @runtime test
1
+ import { createTenantDb, withExecutorManagedWrites, } from "../db/tenant-db.js";
2
+ import { isBoundTenantDb, isDbRunner, tenantDbRunner } from "../db/tenant-db-runner.js";
2
3
  export { resetBlindIndexKeyForTests } from "../crypto/blind-index.js";
3
4
  export { resetEventPiiCatalogForTests } from "../crypto/event-pii.js";
4
5
  export { resetPiiSubjectKmsForTests } from "../crypto/pii-field-encryption.js";
5
6
  export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encryption.js";
7
+ export function withExecutorManagedWritesForTests(db) {
8
+ if (isBoundTenantDb(db)) {
9
+ return createTenantDb({
10
+ db: withExecutorManagedWrites(tenantDbRunner(db)),
11
+ tenantId: db.tenantId,
12
+ mode: db.mode,
13
+ });
14
+ }
15
+ if (isDbRunner(db))
16
+ return withExecutorManagedWrites(db);
17
+ throw new Error("withExecutorManagedWritesForTests: expected a TenantDb or a postgres-js runner");
18
+ }
@@ -3,7 +3,7 @@ import type { Registry } from "../engine/types/index.ts";
3
3
  import type { JobRunner } from "../jobs/index.ts";
4
4
  import type { Logger } from "../logging/types.ts";
5
5
  import { type RebuildResult } from "../pipeline/index.ts";
6
- export declare const pendingRebuildsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
6
+ export declare const pendingRebuildsTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
7
7
  export declare function createPendingRebuildsTable(db: DbConnection): Promise<void>;
8
8
  /** Liest die rebuild-Marker der frisch applizierten Migrations und queued
9
9
  * die betroffenen Tabellen. Upsert: ein bereits pending-er Tisch behält
@@ -2,7 +2,7 @@ import type { DbConnection } from "../db/connection.ts";
2
2
  import { type PiiBackfillFailure } from "../db/queries/backfill-pii.ts";
3
3
  import type { Registry } from "../engine/types/index.ts";
4
4
  import type { Logger } from "../logging/types.ts";
5
- export declare const piiBackfillStateTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
5
+ export declare const piiBackfillStateTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
6
6
  export declare function createPiiBackfillStateTable(db: DbConnection): Promise<void>;
7
7
  export declare function piiAnnotationEntries(registry: Registry): readonly string[];
8
8
  export declare function piiAnnotationFingerprint(registry: Registry): string;
@@ -37,13 +37,20 @@ export const piiBackfillStateTable = pgTable("kumiko_pii_backfill_state", {
37
37
  completedAt: instant("completed_at", { precision: 3 }),
38
38
  updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql `now()`),
39
39
  });
40
+ const PII_EVENT_BACKFILL_DDL_LOCK_KEY = "pii-event-backfill:ddl";
41
+ // CREATE and the follow-up ALTER are not atomic: without the lock a peer that sees the
42
+ // freshly created table skips its own bootstrap and reads before the column exists.
43
+ // The lock holder's transaction only carries the lock; the DDL runs on `db` itself.
40
44
  export async function createPiiBackfillStateTable(db) {
41
- if (!(await tableExists(db, "public.kumiko_pii_backfill_state"))) {
42
- await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
43
- }
44
- // Added after the table's first deploy; IF NOT EXISTS keeps concurrent replica boots safe.
45
- await alterTableAddColumn(db, "kumiko_pii_backfill_state", "failed_event_ids", "bigint[]", " DEFAULT '{}'::bigint[]", " NOT NULL",
46
- /* ifNotExists */ true);
45
+ await db.begin(async (lockTx) => {
46
+ await acquireNamespacedAdvisoryLock(lockTx, PII_EVENT_BACKFILL_LOCK_NAMESPACE, PII_EVENT_BACKFILL_DDL_LOCK_KEY);
47
+ if (!(await tableExists(db, "public.kumiko_pii_backfill_state"))) {
48
+ await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
49
+ }
50
+ // Added after the table's first deploy; IF NOT EXISTS keeps older deployments upgradable.
51
+ await alterTableAddColumn(db, "kumiko_pii_backfill_state", "failed_event_ids", "bigint[]", " DEFAULT '{}'::bigint[]", " NOT NULL",
52
+ /* ifNotExists */ true);
53
+ });
47
54
  }
48
55
  // Replicas boot concurrently: two of them can both pass the tableExists gate and
49
56
  // one CREATE then fails. The table existing afterwards means the other won.
@@ -71,7 +78,7 @@ export function piiAnnotationEntries(registry) {
71
78
  // Bump when the backfill gains a transform stage: completed states keyed by the
72
79
  // old fingerprint must run one more full pass. Kept out of piiAnnotationEntries,
73
80
  // whose emptiness gates no_pii.
74
- const PII_BACKFILL_TRANSFORM_VERSION = "owner-rekey-v2";
81
+ const PII_BACKFILL_TRANSFORM_VERSION = "jsonb-objects-v3";
75
82
  export function piiAnnotationFingerprint(registry) {
76
83
  return createHash("sha256")
77
84
  .update([...piiAnnotationEntries(registry), PII_BACKFILL_TRANSFORM_VERSION].join("\n"))
@@ -3,7 +3,7 @@ import type { Logger } from "../logging/types.ts";
3
3
  import type { Meter } from "./types/index.ts";
4
4
  export declare const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
5
5
  export type EscapeHatchReportWindow = {
6
- shouldReport(key: string, nowMs: number): boolean;
6
+ shouldReport(key: string, nowMs: number, windowMs?: number): boolean;
7
7
  };
8
8
  export declare function createEscapeHatchReportWindow(opts?: {
9
9
  readonly windowMs?: number;
@@ -11,12 +11,12 @@ export function createEscapeHatchReportWindow(opts) {
11
11
  // Map insertion order doubles as recency order: a re-set moves the key to the end.
12
12
  const entries = new Map();
13
13
  return {
14
- shouldReport(key, nowMs) {
14
+ shouldReport(key, nowMs, keyWindowMs = windowMs) {
15
15
  const expiresAt = entries.get(key);
16
16
  if (expiresAt !== undefined && expiresAt > nowMs)
17
17
  return false;
18
18
  entries.delete(key);
19
- entries.set(key, nowMs + windowMs);
19
+ entries.set(key, nowMs + keyWindowMs);
20
20
  while (entries.size > maxEntries) {
21
21
  const oldestKey = entries.keys().next().value;
22
22
  if (oldestKey === undefined)
@@ -27,6 +27,16 @@ export function createEscapeHatchReportWindow(opts) {
27
27
  },
28
28
  };
29
29
  }
30
+ // Cross-tenant reads without a target (every reference lookup of a SystemAdmin list is one)
31
+ // repeat on each page load. One event per actor and hour keeps the trace that a SystemAdmin read
32
+ // other tenants without flooding the audit view; the in-memory window means one event per
33
+ // instance with several instances, which is acceptable. Writes, identity switches and anything
34
+ // aimed at a target keep the short window.
35
+ const READ_WINDOW_MS = 60 * 60 * 1000;
36
+ const READ_WINDOW_KINDS = new Set([
37
+ "acknowledge-cross-tenant",
38
+ "cross-tenant-read",
39
+ ]);
30
40
  // A long-lived worker must leave an audit event inside every retention window
31
41
  // (90 days), or its crons vanish from the log once the first event is pruned.
32
42
  const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -165,8 +175,9 @@ export function createEscapeHatchReporter(opts) {
165
175
  target?.id ?? null,
166
176
  target?.tenantId ?? null,
167
177
  ]);
178
+ const isTargetlessRead = READ_WINDOW_KINDS.has(kind) && target === undefined;
168
179
  // skip: same (handler, kind, reason, target) already reported within the window — dedup
169
- if (!window.shouldReport(key, now()))
180
+ if (!window.shouldReport(key, now(), isTargetlessRead ? READ_WINDOW_MS : undefined))
170
181
  return;
171
182
  reportEscapeHatchUse(eventFor(kind, reason, target), deps);
172
183
  };
@@ -1,7 +1,13 @@
1
- import { deleteMany, fetchOne, selectMany, updateMany } from "../db/query.js";
1
+ import { createEventStoreExecutor } from "../db/event-store-executor.js";
2
+ import { fetchOne, selectMany } from "../db/query.js";
3
+ import { isBoundTenantDb } from "../db/tenant-db-runner.js";
2
4
  import { OnDeleteStrategies, SystemHookNames, SystemHookPriorities } from "../engine/constants.js";
3
5
  import { isRecordLocked } from "../engine/screen-helpers.js";
4
- import { ConflictError, FrameworkReasons, RecordLockedError } from "../errors/index.js";
6
+ import { createSystemUser } from "../engine/system-user.js";
7
+ import { ConflictError, FrameworkReasons, InternalError, RecordLockedError, reraiseAsKumikoError, } from "../errors/index.js";
8
+ // The cascade is a consequence of the parent delete the caller was already authorized for, so the
9
+ // children are written as the framework system user and the lock decision is taken once, up front.
10
+ const CASCADE_CHILD_WRITE_OPTIONS = { unsafeSkipLockGuard: true };
5
11
  export function createCascadeDeleteHook(registry, tables) {
6
12
  return {
7
13
  name: SystemHookNames.cascadeDelete,
@@ -15,6 +21,41 @@ export function createCascadeDeleteHook(registry, tables) {
15
21
  ctx.log?.debug(`cascadeDelete: skipping — ${!entityName ? "no entityName" : "no db"} on payload ${payload.id}`);
16
22
  return;
17
23
  }
24
+ const writeDb = isBoundTenantDb(db) ? db : undefined;
25
+ const cascadeUser = createSystemUser(writeDb?.tenantId ?? payload.event.tenantId);
26
+ const executors = new Map();
27
+ const executorFor = (name, entity) => {
28
+ const cached = executors.get(name);
29
+ if (cached)
30
+ return cached;
31
+ const table = tables.get(name);
32
+ if (!table) {
33
+ throw new InternalError({ message: `cascadeDelete: no table registered for "${name}"` });
34
+ }
35
+ const created = createEventStoreExecutor(table, entity, { entityName: name });
36
+ executors.set(name, created);
37
+ return created;
38
+ };
39
+ const requireWriteDb = () => {
40
+ if (!writeDb) {
41
+ throw new InternalError({
42
+ message: "cascadeDelete: a TenantDb is required to write children",
43
+ });
44
+ }
45
+ return writeDb;
46
+ };
47
+ const unwrap = (result) => {
48
+ if (!result.isSuccess)
49
+ throw reraiseAsKumikoError(result.error);
50
+ };
51
+ const childIds = async (targetTable, where, entity) => {
52
+ // A soft-deleted child is already invisible; deleting it again would fail.
53
+ const rows = await selectMany(db, targetTable, {
54
+ ...where,
55
+ ...(entity?.softDelete === true && { isDeleted: false }),
56
+ });
57
+ return rows.map((row) => row.id);
58
+ };
18
59
  // Cascade applies to outgoing hasMany / manyToMany relations only —
19
60
  // the parent side of the link is where `onDelete` lives (see
20
61
  // relations.ts). Incoming belongsTo edges are covered because their
@@ -33,6 +74,13 @@ export function createCascadeDeleteHook(registry, tables) {
33
74
  const targetTable = tables.get(relation.target);
34
75
  if (!targetTable)
35
76
  continue;
77
+ const childEntity = registry.getEntity(relation.target);
78
+ if (!childEntity &&
79
+ (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull)) {
80
+ throw new InternalError({
81
+ message: `cascadeDelete: child "${relation.target}" is not an entity; changing its rows needs an executor`,
82
+ });
83
+ }
36
84
  if (strategy === OnDeleteStrategies.restrict) {
37
85
  const row = await fetchOne(db, targetTable, { [relation.foreignKey]: payload.id });
38
86
  if (row) {
@@ -48,11 +96,10 @@ export function createCascadeDeleteHook(registry, tables) {
48
96
  });
49
97
  }
50
98
  }
51
- // The raw delete/update below bypasses the executor, so a locked child must
52
- // block the parent delete here instead of changing with it. The error names
99
+ // The child writes below skip the executor's lock guard (decided here once), so a locked
100
+ // child must block the parent delete instead of changing with it. The error names
53
101
  // the parent only: the caller may delete the parent without reading the child.
54
102
  if (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull) {
55
- const childEntity = registry.getEntity(relation.target);
56
103
  const childLock = childEntity?.lockedWhen;
57
104
  if (childLock !== undefined && payload.lockGuardBypassed !== true) {
58
105
  // A soft-deleted child is invisible to the caller and must not block.
@@ -65,11 +112,24 @@ export function createCascadeDeleteHook(registry, tables) {
65
112
  }
66
113
  }
67
114
  }
68
- if (strategy === OnDeleteStrategies.cascade) {
69
- await deleteMany(db, targetTable, { [relation.foreignKey]: payload.id });
115
+ if (strategy === OnDeleteStrategies.cascade && childEntity) {
116
+ const executor = executorFor(relation.target, childEntity);
117
+ for (const id of await childIds(targetTable, { [relation.foreignKey]: payload.id }, childEntity)) {
118
+ unwrap(await executor.delete({ id }, cascadeUser, requireWriteDb(), CASCADE_CHILD_WRITE_OPTIONS));
119
+ }
70
120
  }
71
- if (strategy === OnDeleteStrategies.setNull) {
72
- await updateMany(db, targetTable, { [relation.foreignKey]: null }, { [relation.foreignKey]: payload.id });
121
+ if (strategy === OnDeleteStrategies.setNull && childEntity) {
122
+ const executor = executorFor(relation.target, childEntity);
123
+ const rows = await selectMany(db, targetTable, {
124
+ [relation.foreignKey]: payload.id,
125
+ });
126
+ for (const row of rows) {
127
+ unwrap(await executor.update({ id: row.id, changes: { [relation.foreignKey]: null } }, cascadeUser, requireWriteDb(), {
128
+ ...CASCADE_CHILD_WRITE_OPTIONS,
129
+ skipOptimisticLock: true,
130
+ includeDeleted: true,
131
+ }));
132
+ }
73
133
  }
74
134
  }
75
135
  if (relation.type === "manyToMany" && relation.through) {
@@ -94,7 +154,16 @@ export function createCascadeDeleteHook(registry, tables) {
94
154
  }
95
155
  }
96
156
  if (strategy === OnDeleteStrategies.cascade) {
97
- await deleteMany(db, throughTable, { [sourceKey]: payload.id });
157
+ const throughEntity = registry.getEntity(relation.through.table);
158
+ if (!throughEntity) {
159
+ throw new InternalError({
160
+ message: `cascadeDelete: through table "${relation.through.table}" is not an entity; deleting its rows needs an executor`,
161
+ });
162
+ }
163
+ const executor = executorFor(relation.through.table, throughEntity);
164
+ for (const id of await childIds(throughTable, { [sourceKey]: payload.id }, throughEntity)) {
165
+ unwrap(await executor.delete({ id }, cascadeUser, requireWriteDb(), CASCADE_CHILD_WRITE_OPTIONS));
166
+ }
98
167
  }
99
168
  }
100
169
  }
@@ -1,7 +1,7 @@
1
1
  import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { requestContext, runWithOrigin } from "../api/request-context.js";
3
3
  import { runInSavepoint, selectMany } from "../db/query.js";
4
- import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTransaction, } from "../db/tenant-db.js";
4
+ import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
5
5
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
6
6
  import { mayOverrideTenant } from "../engine/cross-tenant.js";
7
7
  import { createSystemUser } from "../engine/system-user.js";
@@ -166,7 +166,6 @@ export function applyMemberResolutionReadOnly(handlerContext) {
166
166
  // `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
167
167
  // transaction, and its `memberReadOnly` grant denies every raw-runner handout.
168
168
  dbOutsideTransaction: undefined,
169
- outsideTransaction: undefined,
170
169
  write: denyMemberResolutionWrite,
171
170
  writeAs: denyMemberResolutionWrite,
172
171
  // A resolved member cannot switch identity either — the target would get a normal, writable context.
@@ -253,7 +252,6 @@ export function applyBeforeTransactionReadOnly(handlerContext) {
253
252
  return {
254
253
  ...denyTransactionBoundSurfaces(handlerContext),
255
254
  dbOutsideTransaction: undefined,
256
- outsideTransaction: undefined,
257
255
  };
258
256
  }
259
257
  export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction",
@@ -341,11 +339,6 @@ readOnlyForTenant = false) {
341
339
  ? createSystemScopedDbGuard(type, "dbOutsideTransaction", "ctx.systemDb.outsideTransaction.assertTenantMatch(...) or " +
342
340
  "ctx.systemDb.outsideTransaction.acknowledgeCrossTenant(...)")
343
341
  : rawDbOutsideTransaction;
344
- // Not offered to r.systemScope() handlers: the raw outside-tx db is unfiltered in
345
- // system mode, so fn would hand them the same cross-tenant door the guards above close.
346
- const outsideTransaction = !isSystem && rawDbOutsideTransaction
347
- ? (fn) => runInOwnTransaction(rawDbOutsideTransaction, fn)
348
- : undefined;
349
342
  const log = context.log?.child({
350
343
  handler: type,
351
344
  tenantId: user.tenantId,
@@ -861,7 +854,6 @@ readOnlyForTenant = false) {
861
854
  registry,
862
855
  db: exposedDb,
863
856
  dbOutsideTransaction,
864
- ...(outsideTransaction && { outsideTransaction }),
865
857
  ...(systemDb && { systemDb }),
866
858
  log,
867
859
  notify,
@@ -5,7 +5,7 @@ export type PendingGapEntry = {
5
5
  readonly to: string;
6
6
  readonly xmax: string;
7
7
  };
8
- export declare const eventConsumerStateTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
8
+ export declare const eventConsumerStateTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
9
9
  export declare const ConsumerStatuses: {
10
10
  readonly idle: "idle";
11
11
  readonly processing: "processing";
@@ -1,6 +1,7 @@
1
1
  import { markConsumerRebuildFailed, resetConsumerForMspRebuild, selectConsumerForUpdate, selectEventIdHorizonWithMissingRanges, selectSnapshotXmax, updateConsumerRebuildCursor, } from "../db/queries/event-consumer.js";
2
2
  import { assertLiveColumnsMatchMeta, assertLiveTableHasNoRowLevelSecurity, buildShadowTable, ensureRebuildSchema, rebuildMetaOrThrow, swapShadowIntoLive, } from "../db/queries/shadow-swap.js";
3
3
  import { runInSavepoint, selectMany } from "../db/query.js";
4
+ import { withExecutorManagedWrites } from "../db/tenant-db.js";
4
5
  import { InternalError } from "../errors/index.js";
5
6
  import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store.js";
6
7
  import { eventsTable, upcastStoredEvent } from "../event-store/index.js";
@@ -70,6 +71,7 @@ export async function rebuildMultiStreamProjection(mspName, deps) {
70
71
  // paths from ever double-applying the same event.
71
72
  const events = await selectMany(tx, eventsTable, { type: [...subscribedTypes], id: { lte: horizon } }, { orderBy: { col: "id", direction: "asc" } });
72
73
  const upcasters = registry.getEventUpcasters();
74
+ const applyTx = withExecutorManagedWrites(tx);
73
75
  for (const row of events) {
74
76
  deps.signal?.throwIfAborted();
75
77
  const raw = {
@@ -99,7 +101,7 @@ export async function rebuildMultiStreamProjection(mspName, deps) {
99
101
  // reject the whole begin() even after a caught error. Apply +
100
102
  // ctx run on the savepoint-scoped handle.
101
103
  try {
102
- await runInSavepoint(tx, async (sp) => {
104
+ await runInSavepoint(applyTx, async (sp) => {
103
105
  const rebuildCtx = createRebuildCtx(registry, sp, row.tenantId);
104
106
  await applyFn(storedEvent, sp, rebuildCtx);
105
107
  });
@@ -109,8 +111,8 @@ export async function rebuildMultiStreamProjection(mspName, deps) {
109
111
  }
110
112
  }
111
113
  else {
112
- const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId);
113
- await applyFn(storedEvent, tx, rebuildCtx);
114
+ const rebuildCtx = createRebuildCtx(registry, applyTx, row.tenantId);
115
+ await applyFn(storedEvent, applyTx, rebuildCtx);
114
116
  }
115
117
  eventsProcessed++;
116
118
  lastProcessedEventId = row.id;
@@ -1,6 +1,7 @@
1
1
  import { countSubscribedEvents, finalizeProjectionRebuild, markProjectionRebuildFailed, markProjectionRebuilding, selectEventsForProjectionRebuildBatch, } from "../db/queries/projection-rebuild.js";
2
2
  import { assertLiveColumnsMatchMeta, assertLiveTableHasNoRowLevelSecurity, assertNoBlindIndexLoss, assertNoUnreachableLiveRows, buildShadowTable, countColumnDrift, ensureRebuildSchema, fenceLiveTable, rebuildMetaOrThrow, swapShadowIntoLive, } from "../db/queries/shadow-swap.js";
3
3
  import { coerceRow, extractTableInfo, runInSavepoint, selectMany } from "../db/query.js";
4
+ import { withExecutorManagedWrites } from "../db/tenant-db.js";
4
5
  import { eventsTable, getEventsHighWaterMark, upcastStoredEvent, } from "../event-store/index.js";
5
6
  import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../event-store/rebuild-dead-letter.js";
6
7
  import { emitProjectionRebuild } from "../observability/standard-metrics.js";
@@ -74,6 +75,7 @@ export async function rebuildProjection(projectionName, deps) {
74
75
  // (a short batch = no more currently-committed events).
75
76
  const drainBatch = async (tx) => {
76
77
  const rawEvents = await selectEventsForProjectionRebuildBatch(tx, sourcesList, subscribedList, lastProcessedEventId, REBUILD_BATCH_SIZE);
78
+ const applyTx = withExecutorManagedWrites(tx);
77
79
  for (const r of rawEvents) {
78
80
  deps.signal?.throwIfAborted();
79
81
  const row = coerceRow(r, eventsInfo);
@@ -92,7 +94,7 @@ export async function rebuildProjection(projectionName, deps) {
92
94
  // begin() even after a caught error. The apply runs on the
93
95
  // savepoint-scoped handle so its statements are confined.
94
96
  try {
95
- await runInSavepoint(tx, async (sp) => {
97
+ await runInSavepoint(applyTx, async (sp) => {
96
98
  await applyFn(storedEvent, sp, projection.table);
97
99
  });
98
100
  }
@@ -101,7 +103,7 @@ export async function rebuildProjection(projectionName, deps) {
101
103
  }
102
104
  }
103
105
  else {
104
- await applyFn(storedEvent, tx, projection.table);
106
+ await applyFn(storedEvent, applyTx, projection.table);
105
107
  }
106
108
  eventsProcessed++;
107
109
  lastProcessedEventId = row.id;
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "../db/connection.ts";
2
- export declare const projectionStateTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
2
+ export declare const projectionStateTable: import("@cosmicdrift/kumiko-types/executor-brand").WritableSchemaTable;
3
3
  export declare const ProjectionStatuses: {
4
4
  readonly idle: "idle";
5
5
  readonly rebuilding: "rebuilding";
@@ -1,3 +1,4 @@
1
+ import { withExecutorManagedWrites } from "../db/tenant-db.js";
1
2
  import { InternalError } from "../errors/index.js";
2
3
  // Run custom projections for a save or delete result. Lives INSIDE the
3
4
  // transaction that appended the event — a throw from apply() rolls the event
@@ -59,7 +60,7 @@ export async function runProjectionsForEvent(event, registry, tx) {
59
60
  for (const proj of liveProjectionsForEvent(event, registry)) {
60
61
  const applyFn = proj.apply[event.type];
61
62
  if (applyFn)
62
- await applyFn(event, tx, proj.table);
63
+ await applyFn(event, withExecutorManagedWrites(tx), proj.table);
63
64
  }
64
65
  projectedEvents.add(event);
65
66
  }
@@ -1,4 +1,4 @@
1
1
  export { type BucketContext, type BucketResult, buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.ts";
2
- export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.ts";
2
+ export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, DEFAULT_GLOBAL_RATE_LIMIT, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.ts";
3
3
  export { createRateLimitResolver, type RateLimitBucketRequest, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
4
4
  export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.ts";
@@ -1,4 +1,4 @@
1
1
  export { buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.js";
2
- export { authEndpointRateLimit, globalIpRateLimit, httpRouteRateLimit, } from "./middleware.js";
2
+ export { authEndpointRateLimit, DEFAULT_GLOBAL_RATE_LIMIT, globalIpRateLimit, httpRouteRateLimit, } from "./middleware.js";
3
3
  export { createRateLimitResolver, } from "./resolver.js";
4
4
  export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.js";
@@ -11,6 +11,10 @@ export type GlobalIpRateLimitOptions = {
11
11
  readonly extractIp?: (c: Context) => string | undefined;
12
12
  readonly onFailClosed?: (err: unknown) => void;
13
13
  };
14
+ export declare const DEFAULT_GLOBAL_RATE_LIMIT: {
15
+ readonly limit: 600;
16
+ readonly windowSeconds: 60;
17
+ };
14
18
  export declare function globalIpRateLimit(opts: GlobalIpRateLimitOptions): MiddlewareHandler;
15
19
  export type HttpRouteRateLimitOptions = {
16
20
  readonly resolver: RateLimitResolver;
@@ -3,9 +3,12 @@ import { clientIpSourceFromHonoContext, createClientIpResolver, } from "../api/c
3
3
  import { requestContext } from "../api/request-context.js";
4
4
  import { RateLimitError, serializeError } from "../errors/index.js";
5
5
  import { toPublicBucketName } from "./bucket.js";
6
+ // Shared with runProdApp (server-runtime re-exports it). The auth middleware default stays
7
+ // stricter than runProdApp's auth limit (30): a bare authEndpointRateLimit guards brute force.
8
+ export const DEFAULT_GLOBAL_RATE_LIMIT = { limit: 600, windowSeconds: 60 };
6
9
  export function globalIpRateLimit(opts) {
7
- const limit = opts.limit ?? 1000;
8
- const windowSeconds = opts.windowSeconds ?? 60;
10
+ const limit = opts.limit ?? DEFAULT_GLOBAL_RATE_LIMIT.limit;
11
+ const windowSeconds = opts.windowSeconds ?? DEFAULT_GLOBAL_RATE_LIMIT.windowSeconds;
9
12
  const clientIpResolver = opts.clientIpResolver ??
10
13
  createClientIpResolver(opts.trustedProxyHops ?? 0, "globalIpRateLimit");
11
14
  const extractIp = opts.extractIp ?? ((c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c)));
@@ -6,7 +6,7 @@ export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperan
6
6
  export type { ParsedRefTarget } from "../engine/parse-ref-target.ts";
7
7
  export { parseRefTarget } from "../engine/parse-ref-target.ts";
8
8
  export type { FieldsOrGroupsSection, UnavailableSelectOption } from "../engine/screen-helpers.ts";
9
- export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.ts";
9
+ export { ACTION_FORM_PSEUDO_ENTITY, availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.ts";
10
10
  export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.ts";
11
11
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.ts";
12
12
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.ts";
@@ -22,5 +22,5 @@ export type { CardColumnRoles, CardRoleColumn } from "./card-column-roles.ts";
22
22
  export { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind } from "./card-column-roles.ts";
23
23
  export { type Formality, formalLocaleTag } from "./formality.ts";
24
24
  export type { ListRowMetaColumnType, ListRowMetaReference, ReferenceLookupSource, SystemReferenceLabel, } from "./list-row-meta.ts";
25
- export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "./list-row-meta.ts";
25
+ export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, UNRESOLVED_REFERENCE_LABEL_KEYS, } from "./list-row-meta.ts";
26
26
  export { type PluralForms, resolveTranslationValue, type TranslationValue, translationValueOtherText, } from "./plural.ts";
@@ -23,11 +23,11 @@ export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
23
23
  export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.js";
24
24
  export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperand, } from "../engine/form-derivations.js";
25
25
  export { parseRefTarget } from "../engine/parse-ref-target.js";
26
- export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
26
+ export { ACTION_FORM_PSEUDO_ENTITY, availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
27
27
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
28
28
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
29
29
  export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
30
30
  export { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind } from "./card-column-roles.js";
31
31
  export { formalLocaleTag } from "./formality.js";
32
- export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "./list-row-meta.js";
32
+ export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, UNRESOLVED_REFERENCE_LABEL_KEYS, } from "./list-row-meta.js";
33
33
  export { resolveTranslationValue, translationValueOtherText, } from "./plural.js";
@@ -13,6 +13,9 @@ export type ReferenceLookupSource = {
13
13
  export declare const REFERENCE_LOOKUP_SOURCES: Readonly<Record<string, ReferenceLookupSource>>;
14
14
  export type SystemReferenceLabel = {
15
15
  readonly id: string;
16
+ /** Further literals that mean the same system identity (e.g. the "system" createdBy literal). */
17
+ readonly aliases?: readonly string[];
16
18
  readonly labelKey: string;
17
19
  };
18
20
  export declare const SYSTEM_REFERENCE_LABELS: Readonly<Record<string, SystemReferenceLabel>>;
21
+ export declare const UNRESOLVED_REFERENCE_LABEL_KEYS: Readonly<Record<string, string>>;
@@ -61,5 +61,16 @@ export const SYSTEM_REFERENCE_LABELS = {
61
61
  "tenant:tenant": { id: SYSTEM_TENANT_ID, labelKey: "kumiko.reference.system-tenant" },
62
62
  // createdBy on a system write (fw#3103) — SYSTEM_USER_ID is an alias for
63
63
  // "no human caller", so read_users never holds a matching row.
64
- "user:user": { id: SYSTEM_USER_ID, labelKey: "kumiko.reference.system-user" },
64
+ "user:user": {
65
+ id: SYSTEM_USER_ID,
66
+ aliases: ["system"],
67
+ labelKey: "kumiko.reference.system-user",
68
+ },
69
+ };
70
+ // Label for a reference id that is neither a system identity nor has a
71
+ // visible row (deleted user, seed actor, row outside the caller's scope).
72
+ // Without it a reference cell falls back to the raw UUID. Opt-in per entity:
73
+ // for entities whose id is meaningful to the reader the raw id stays.
74
+ export const UNRESOLVED_REFERENCE_LABEL_KEYS = {
75
+ "user:user": "kumiko.reference.unknown-user",
65
76
  };
@@ -0,0 +1,6 @@
1
+ [
2
+ {
3
+ "module": "files/file-ref-table",
4
+ "export": "fileRefsTable"
5
+ }
6
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.378.0",
3
+ "version": "0.380.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -199,7 +199,7 @@
199
199
  "./package.json": "./package.json"
200
200
  },
201
201
  "dependencies": {
202
- "@cosmicdrift/kumiko-types": "0.378.0",
202
+ "@cosmicdrift/kumiko-types": "0.380.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-testing": "0.378.0",
218
+ "@cosmicdrift/kumiko-testing": "0.380.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -394,6 +394,7 @@
394
394
  },
395
395
  "files": [
396
396
  "dist",
397
+ "kumiko-es-tables.json",
397
398
  "presets",
398
399
  "src/changes.json",
399
400
  "src/scripts/codemod",