@cosmicdrift/kumiko-framework 0.377.0 → 0.379.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 (118) 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/delivery-notifications.md +25 -2
  5. package/agents/examples/embedded.md +177 -0
  6. package/agents/examples/event-sourcing.md +6 -5
  7. package/agents/examples/legal-pages.md +5 -5
  8. package/agents/examples/reference-data.md +26 -21
  9. package/agents/examples/relations.md +2 -1
  10. package/agents/features.md +5 -5
  11. package/agents/widgets.md +1 -0
  12. package/dist/api/entity-read-access.d.ts +2 -0
  13. package/dist/api/entity-read-access.js +27 -0
  14. package/dist/api/index.d.ts +1 -0
  15. package/dist/api/index.js +1 -0
  16. package/dist/api/server.js +10 -3
  17. package/dist/api/sse-route.d.ts +5 -0
  18. package/dist/api/sse-route.js +59 -9
  19. package/dist/db/entity-table-from-registry.d.ts +2 -1
  20. package/dist/db/entity-table-meta.d.ts +0 -2
  21. package/dist/db/entity-table-meta.js +1 -3
  22. package/dist/db/event-store-executor-write.js +21 -16
  23. package/dist/db/executor-managed-tables.d.ts +6 -0
  24. package/dist/db/executor-managed-tables.js +40 -0
  25. package/dist/db/index.d.ts +4 -2
  26. package/dist/db/index.js +4 -2
  27. package/dist/db/money.d.ts +3 -2
  28. package/dist/db/money.js +2 -7
  29. package/dist/db/queries/backfill-pii.js +22 -12
  30. package/dist/db/queries/database-now.d.ts +5 -0
  31. package/dist/db/queries/database-now.js +12 -0
  32. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  33. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  34. package/dist/db/query.js +28 -0
  35. package/dist/db/reference-data.d.ts +5 -4
  36. package/dist/db/reference-data.js +23 -21
  37. package/dist/db/table-builder.js +2 -0
  38. package/dist/db/tenant-db-runner.d.ts +2 -0
  39. package/dist/db/tenant-db-runner.js +7 -0
  40. package/dist/db/tenant-db.d.ts +3 -0
  41. package/dist/db/tenant-db.js +54 -18
  42. package/dist/engine/boot-validator/entity-handler.js +12 -4
  43. package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
  44. package/dist/engine/boot-validator/form-derivations.js +82 -0
  45. package/dist/engine/boot-validator/query-refs.js +8 -0
  46. package/dist/engine/boot-validator/screens.js +50 -12
  47. package/dist/engine/build-config-feature-schema.js +33 -10
  48. package/dist/engine/embedded-derived.d.ts +1 -1
  49. package/dist/engine/embedded-derived.js +7 -1
  50. package/dist/engine/feature-ui-extensions.js +4 -0
  51. package/dist/engine/form-derivations.d.ts +21 -0
  52. package/dist/engine/form-derivations.js +175 -0
  53. package/dist/engine/index.d.ts +4 -1
  54. package/dist/engine/index.js +2 -0
  55. package/dist/engine/minor-units.d.ts +2 -0
  56. package/dist/engine/minor-units.js +8 -0
  57. package/dist/engine/registry-state.js +2 -0
  58. package/dist/engine/registry-validate.js +4 -1
  59. package/dist/engine/schema-builder.js +18 -4
  60. package/dist/engine/screen-helpers.d.ts +1 -0
  61. package/dist/engine/screen-helpers.js +3 -0
  62. package/dist/engine/soft-delete-cleanup.js +53 -24
  63. package/dist/engine/steps/unsafe-projection-delete.js +2 -1
  64. package/dist/engine/template-variables.d.ts +2 -1
  65. package/dist/engine/template-variables.js +4 -0
  66. package/dist/engine/types/index.d.ts +2 -2
  67. package/dist/engine/with-form-derivations.d.ts +9 -0
  68. package/dist/engine/with-form-derivations.js +16 -0
  69. package/dist/entrypoint/index.d.ts +1 -3
  70. package/dist/errors/reasons.d.ts +1 -0
  71. package/dist/errors/reasons.js +4 -1
  72. package/dist/errors/serialize.d.ts +1 -0
  73. package/dist/errors/serialize.js +9 -5
  74. package/dist/errors/write-error-info.js +3 -2
  75. package/dist/errors/zod-bridge.js +7 -0
  76. package/dist/event-store/index.d.ts +1 -0
  77. package/dist/event-store/index.js +1 -0
  78. package/dist/files/action-form-upload.d.ts +16 -0
  79. package/dist/files/action-form-upload.js +45 -0
  80. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  81. package/dist/files/bind-pending-file-refs.js +44 -4
  82. package/dist/files/create-file-ref.d.ts +3 -0
  83. package/dist/files/create-file-ref.js +41 -13
  84. package/dist/files/file-routes.js +29 -2
  85. package/dist/files/index.d.ts +2 -1
  86. package/dist/files/index.js +1 -0
  87. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  88. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  89. package/dist/i18n/settings-hub-keys.js +1 -0
  90. package/dist/internal/testing.d.ts +2 -0
  91. package/dist/internal/testing.js +15 -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/msp-rebuild.js +5 -3
  98. package/dist/pipeline/projection-rebuild.js +4 -2
  99. package/dist/pipeline/projections-runner.js +2 -1
  100. package/dist/rate-limit/index.d.ts +1 -1
  101. package/dist/rate-limit/index.js +1 -1
  102. package/dist/rate-limit/middleware.d.ts +4 -0
  103. package/dist/rate-limit/middleware.js +5 -2
  104. package/dist/search/configure-tenant-index.d.ts +4 -0
  105. package/dist/search/configure-tenant-index.js +8 -0
  106. package/dist/search/index.d.ts +1 -0
  107. package/dist/search/index.js +1 -0
  108. package/dist/ui-types/index.d.ts +4 -2
  109. package/dist/ui-types/index.js +3 -2
  110. package/dist/ui-types/list-row-meta.d.ts +3 -0
  111. package/dist/ui-types/list-row-meta.js +12 -1
  112. package/kumiko-es-tables.json +6 -0
  113. package/package.json +4 -3
  114. package/src/changes.json +142 -0
  115. package/src/scripts/codemod/README.md +20 -0
  116. package/src/scripts/codemod/codemod-support.ts +1 -1
  117. package/src/scripts/codemod/feature-layout.ts +416 -0
  118. package/src/scripts/codemod/package-merge.ts +0 -11
@@ -3,6 +3,7 @@ import { runInOwnTransaction } from "../db/tenant-db.js";
3
3
  import { isFileField, } from "../engine/types/index.js";
4
4
  import { InternalError } from "../errors/index.js";
5
5
  import { generateId } from "../utils/index.js";
6
+ import { isActionFormScreenQn, resolveActionFormFileField } from "./action-form-upload.js";
6
7
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
7
8
  import { fileRefsTable } from "./file-ref-table.js";
8
9
  import { resolveContentType } from "./resolve-content-type.js";
@@ -23,6 +24,7 @@ export function getFileRefExecutor() {
23
24
  fileRefExecutor ??= buildFileRefExecutor();
24
25
  return fileRefExecutor;
25
26
  }
27
+ const ACTION_FORM_STORAGE_SEGMENT = "action-form";
26
28
  const SAFE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
27
29
  // entityType/fieldName drive the GDPR-forget decision downstream
28
30
  // (fileRefDeleteHook resolves the field's PII annotation from exactly this
@@ -39,6 +41,33 @@ export function resolveAttachedField(registry, entityType, fieldName) {
39
41
  return { kind: "unresolvable" };
40
42
  return { kind: "resolved", fieldDef };
41
43
  }
44
+ function resolveUploadField(registry, input) {
45
+ const { entityType, entityId, fieldName, actionForm } = input;
46
+ if (actionForm === undefined)
47
+ return resolveAttachedField(registry, entityType, fieldName);
48
+ if (entityType !== undefined || entityId !== undefined) {
49
+ return {
50
+ kind: "rejected",
51
+ error: "invalid_action_form: actionForm cannot be combined with entityType/entityId",
52
+ };
53
+ }
54
+ if (!isActionFormScreenQn(actionForm)) {
55
+ return {
56
+ kind: "rejected",
57
+ error: "invalid_action_form: expected a screen QN like <feature>:screen:<id>",
58
+ };
59
+ }
60
+ const fieldDef = fieldName !== undefined
61
+ ? resolveActionFormFileField(registry, actionForm, fieldName)
62
+ : undefined;
63
+ if (fieldDef === undefined) {
64
+ return {
65
+ kind: "rejected",
66
+ error: "unresolvable_field: actionForm and fieldName must name a files/image field of a registered actionForm screen",
67
+ };
68
+ }
69
+ return { kind: "resolved", fieldDef };
70
+ }
42
71
  function acceptFile(deps, input) {
43
72
  const { fileName, declaredMimeType, data, entityType, entityId, fieldName } = input;
44
73
  const rejected = (error) => ({ kind: "rejected", error });
@@ -54,18 +83,15 @@ function acceptFile(deps, input) {
54
83
  return rejected(`invalid_${field}: must match /^[A-Za-z0-9_-]+$/`);
55
84
  }
56
85
  }
57
- let maxSize = deps.defaultMaxUploadSize;
58
- let accept;
59
- const attachedField = resolveAttachedField(deps.registry, entityType, fieldName);
60
- if (attachedField.kind === "unresolvable") {
86
+ const field = resolveUploadField(deps.registry, input);
87
+ if (field.kind === "rejected")
88
+ return field;
89
+ if (field.kind === "unresolvable") {
61
90
  return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
62
91
  }
63
- if (attachedField.kind === "resolved") {
64
- if (attachedField.fieldDef.maxSize)
65
- maxSize = attachedField.fieldDef.maxSize;
66
- if (attachedField.fieldDef.accept)
67
- accept = attachedField.fieldDef.accept;
68
- }
92
+ const fieldDef = field.kind === "resolved" ? field.fieldDef : undefined;
93
+ const maxSize = fieldDef?.maxSize || deps.defaultMaxUploadSize;
94
+ const accept = fieldDef?.accept;
69
95
  const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
70
96
  if (validationError)
71
97
  return rejected(validationError);
@@ -98,12 +124,14 @@ function acceptFile(deps, input) {
98
124
  // Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
99
125
  // I/O doesn't belong inside a PG connection's tx window.
100
126
  export async function writeAcceptedFileBytes(deps, input) {
101
- const { fileName, data, entityType, entityId, fieldName } = input;
127
+ const { fileName, data, entityType, entityId, fieldName, actionForm } = input;
102
128
  const accepted = acceptFile(deps, input);
103
129
  if (accepted.kind === "rejected")
104
130
  return accepted;
105
131
  const { mimeType, extension } = accepted;
106
- const storageKey = buildStorageKey(deps.user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", fileName, generateId(), extension);
132
+ const storageKey = buildStorageKey(deps.user.tenantId,
133
+ // The screen QN stays out of the key: a fixed segment keeps it a plain path part.
134
+ actionForm !== undefined ? ACTION_FORM_STORAGE_SEGMENT : (entityType ?? "unattached"), entityId, fieldName ?? "file", fileName, generateId(), extension);
107
135
  await deps.writeBytes(storageKey, data, mimeType);
108
136
  return {
109
137
  kind: "uploaded",
@@ -112,7 +140,7 @@ export async function writeAcceptedFileBytes(deps, input) {
112
140
  fileName,
113
141
  mimeType,
114
142
  size: data.byteLength,
115
- entityType,
143
+ entityType: actionForm ?? entityType,
116
144
  entityId,
117
145
  fieldName,
118
146
  },
@@ -6,8 +6,9 @@ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues
6
6
  import { selectMany } from "../db/query.js";
7
7
  import { createProjectingTenantDb } from "../db/tenant-db.js";
8
8
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer, } from "../derivatives/index.js";
9
- import { SYSTEM_USER_ID } from "../engine/system-user.js";
9
+ import { isAnonymousSessionUser, SYSTEM_USER_ID } from "../engine/system-user.js";
10
10
  import { isFileField, isUuid, } from "../engine/types/index.js";
11
+ import { listActionFormFileFields, mayUseActionForm } from "./action-form-upload.js";
11
12
  import { buildContentDispositionHeader } from "./content-disposition.js";
12
13
  import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
13
14
  import { createFileContext } from "./file-handle.js";
@@ -70,6 +71,10 @@ export function resolveMaxUploadBodyBytes(options) {
70
71
  }
71
72
  }
72
73
  }
74
+ for (const { fieldDef } of listActionFormFileFields(options.registry)) {
75
+ if (fieldDef.maxSize)
76
+ maxBytes = Math.max(maxBytes, parseMaxSize(fieldDef.maxSize));
77
+ }
73
78
  return maxBytes + MULTIPART_OVERHEAD_MARGIN_BYTES;
74
79
  }
75
80
  // Upload-route policy carried by createFilesFeature(opts?) — read from the
@@ -148,6 +153,20 @@ export function createFileRoutes(options) {
148
153
  // store it in the text entityId column.
149
154
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
150
155
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
156
+ const actionForm = typeof body["actionForm"] === "string" ? body["actionForm"] : undefined;
157
+ // Ahead of the record check below, which would answer 404/403 for the entity half.
158
+ if (actionForm !== undefined && (entityType !== undefined || entityId !== undefined)) {
159
+ return c.json({ error: "invalid_action_form: actionForm cannot be combined with entityType or entityId" }, 400);
160
+ }
161
+ // Before any bytes are written, like the record check below. An unresolvable screen falls
162
+ // through to storeFileAsFileRef, which answers 400; a known one must be submittable by the
163
+ // caller. Anonymous callers cannot bind pending uploads, so they get no upload either.
164
+ if (actionForm !== undefined &&
165
+ options.registry !== undefined &&
166
+ options.registry.getScreen(actionForm)?.type === "actionForm" &&
167
+ (isAnonymousSessionUser(user) || !mayUseActionForm(options.registry, actionForm, user))) {
168
+ return c.json({ error: "forbidden: no access to this form" }, 403);
169
+ }
151
170
  if (entityId !== undefined) {
152
171
  if (entityType === undefined) {
153
172
  return c.json({ error: "missing_entity_type: entityId requires entityType" }, 400);
@@ -184,7 +203,15 @@ export function createFileRoutes(options) {
184
203
  await storageProvider.write(key, bytes, mimeType);
185
204
  },
186
205
  deleteBytes: (key) => storageProvider.delete(key),
187
- }, { fileName: file.name, declaredMimeType: file.type, data, entityType, entityId, fieldName });
206
+ }, {
207
+ fileName: file.name,
208
+ declaredMimeType: file.type,
209
+ data,
210
+ entityType,
211
+ entityId,
212
+ fieldName,
213
+ actionForm,
214
+ });
188
215
  if (outcome.kind === "rejected")
189
216
  return c.json({ error: outcome.error }, 400);
190
217
  if (outcome.kind === "failed")
@@ -1,4 +1,5 @@
1
- export type { BindPendingFileRefOutcome, PendingFileRef } from "./bind-pending-file-refs.ts";
1
+ export { listActionFormFileFields, resolveActionFormFileField } from "./action-form-upload.ts";
2
+ export type { BindPendingFileRefOutcome, PendingFileRef, PendingFileRefBindTarget, } from "./bind-pending-file-refs.ts";
2
3
  export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.ts";
3
4
  export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.ts";
4
5
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
@@ -1,3 +1,4 @@
1
+ export { listActionFormFileFields, resolveActionFormFileField } from "./action-form-upload.js";
1
2
  export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.js";
2
3
  export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
3
4
  export { createFilesFeature, FILES_FEATURE } from "./feature.js";
@@ -1,10 +1,10 @@
1
1
  import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
2
2
  import type { Logger } from "@cosmicdrift/kumiko-types/logger";
3
- import { deleteMany, type WhereObject } from "../db/query.ts";
3
+ import { selectMany, type WhereObject } from "../db/query.ts";
4
4
  import type { TenantDb } from "../db/tenant-db.ts";
5
5
  export declare const EXPIRED_FILE_REF_PURGE_BATCH = 500;
6
6
  type PurgeLog = Pick<Logger, "warn"> | undefined;
7
- type PurgeTable = Parameters<typeof deleteMany>[1];
7
+ type PurgeTable = Parameters<typeof selectMany>[1];
8
8
  export declare function purgeExpiredFileRefsWithBytes(args: {
9
9
  readonly db: TenantDb;
10
10
  readonly table: PurgeTable;
@@ -12,6 +12,7 @@ export declare function purgeExpiredFileRefsWithBytes(args: {
12
12
  readonly expiredBefore: Temporal.Instant;
13
13
  readonly files: FileContext | undefined;
14
14
  readonly log: PurgeLog;
15
+ readonly forgetRow: (id: string) => Promise<void>;
15
16
  readonly pageSize?: number;
16
17
  }): Promise<void>;
17
18
  export {};
@@ -1,4 +1,4 @@
1
- import { deleteMany, selectMany } from "../db/query.js";
1
+ import { selectMany } from "../db/query.js";
2
2
  import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
3
3
  export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
4
  const PAGE_ORDER = [
@@ -20,12 +20,12 @@ async function selectNextPage(args) {
20
20
  return [...sameInstant, ...later];
21
21
  }
22
22
  // Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
23
- // rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
23
+ // rows (forgotten through the executor, so a rebuild keeps them gone). Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
24
24
  // row whose bytes could not be removed stays and the next run retries it. A run walks the
25
25
  // expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
26
26
  // rows behind them; the run ends on the first empty page.
27
27
  export async function purgeExpiredFileRefsWithBytes(args) {
28
- const { db, table, where, expiredBefore, files, log } = args;
28
+ const { db, table, where, expiredBefore, files, log, forgetRow } = args;
29
29
  const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
30
30
  if (!files) {
31
31
  log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
@@ -53,7 +53,13 @@ export async function purgeExpiredFileRefsWithBytes(args) {
53
53
  log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
54
54
  }
55
55
  }
56
- if (purgedIds.length > 0)
57
- await deleteMany(db, table, { ...where, id: { in: purgedIds } });
56
+ for (const id of purgedIds) {
57
+ try {
58
+ await forgetRow(id);
59
+ }
60
+ catch (err) {
61
+ log?.warn(`[soft-delete-cleanup] fileRef ${id} bytes deleted but row not forgotten: ${err instanceof Error ? err.message : String(err)}`);
62
+ }
63
+ }
58
64
  }
59
65
  }
@@ -19,6 +19,7 @@ export const SETTINGS_HUB_I18N = {
19
19
  en: "Choose a provider. Its settings appear below and are saved together with it.",
20
20
  },
21
21
  "config.settings.provider": { en: "Provider" },
22
+ "config.settings.provider.platformDefault": { en: "Platform default" },
22
23
  "config.settings.audience.system": {
23
24
  en: "Applies to every tenant unless a tenant sets its own value.",
24
25
  },
@@ -1,4 +1,6 @@
1
+ import type { AnyDb } from "../db/query.ts";
1
2
  export { resetBlindIndexKeyForTests } from "../crypto/blind-index.ts";
2
3
  export { resetEventPiiCatalogForTests } from "../crypto/event-pii.ts";
3
4
  export { resetPiiSubjectKmsForTests } from "../crypto/pii-field-encryption.ts";
4
5
  export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encryption.ts";
6
+ export declare function withExecutorManagedWritesForTests(db: AnyDb): AnyDb;
@@ -1,5 +1,19 @@
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
+ // Seeds and teardown write read-model rows without events; the capability is the sanctioned test seam.
8
+ export function withExecutorManagedWritesForTests(db) {
9
+ if (isBoundTenantDb(db)) {
10
+ return createTenantDb({
11
+ db: withExecutorManagedWrites(tenantDbRunner(db)),
12
+ tenantId: db.tenantId,
13
+ mode: db.mode,
14
+ });
15
+ }
16
+ if (isDbRunner(db))
17
+ return withExecutorManagedWrites(db);
18
+ throw new Error("withExecutorManagedWritesForTests: expected a TenantDb or a postgres-js runner");
19
+ }
@@ -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,
@@ -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,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";