@cosmicdrift/kumiko-framework 0.363.0 → 0.364.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 (189) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +7 -0
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/routes.js +46 -17
  80. package/dist/api/server.d.ts +2 -1
  81. package/dist/api/server.js +18 -6
  82. package/dist/api/session-cookies.d.ts +1 -0
  83. package/dist/api/session-cookies.js +20 -5
  84. package/dist/api/sse-route.js +16 -1
  85. package/dist/bun-db/index.d.ts +1 -1
  86. package/dist/bun-db/index.js +1 -1
  87. package/dist/bun-db/query.d.ts +8 -3
  88. package/dist/bun-db/query.js +27 -7
  89. package/dist/db/collect-table-metas.js +11 -7
  90. package/dist/db/eagerload.js +2 -2
  91. package/dist/db/event-store-executor-context.d.ts +5 -6
  92. package/dist/db/event-store-executor-context.js +24 -22
  93. package/dist/db/event-store-executor-read.js +31 -16
  94. package/dist/db/event-store-executor-write.js +18 -20
  95. package/dist/db/index.d.ts +1 -1
  96. package/dist/db/index.js +1 -1
  97. package/dist/db/lock-guard.d.ts +4 -0
  98. package/dist/db/lock-guard.js +14 -0
  99. package/dist/db/migrate-generator.js +1 -1
  100. package/dist/db/migrate-runner.js +9 -8
  101. package/dist/db/queries/event-consumer.d.ts +6 -6
  102. package/dist/db/queries/event-store.d.ts +1 -1
  103. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  104. package/dist/db/tenant-db-runner.d.ts +4 -0
  105. package/dist/db/tenant-db-runner.js +17 -0
  106. package/dist/db/tenant-db.d.ts +2 -0
  107. package/dist/db/tenant-db.js +30 -4
  108. package/dist/derivatives/derivatives-context.js +1 -1
  109. package/dist/engine/boot-validator/access-declarations.js +2 -2
  110. package/dist/engine/boot-validator/api-ext.js +3 -3
  111. package/dist/engine/boot-validator/boot-check.js +1 -1
  112. package/dist/engine/boot-validator/config-deps.js +13 -13
  113. package/dist/engine/boot-validator/detail-screens.js +1 -1
  114. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  115. package/dist/engine/boot-validator/entity-handler.js +84 -35
  116. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  117. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  118. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  119. package/dist/engine/boot-validator/nav.js +1 -1
  120. package/dist/engine/boot-validator/pii-retention.js +8 -8
  121. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  122. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  123. package/dist/engine/boot-validator/screens.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens.js +153 -95
  125. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  126. package/dist/engine/define-handler.d.ts +2 -2
  127. package/dist/engine/define-handler.js +8 -1
  128. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  129. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  130. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  131. package/dist/engine/feature-ast/render.js +10 -2
  132. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  133. package/dist/engine/feature-entity-handlers.js +12 -3
  134. package/dist/engine/handler-field-references.d.ts +21 -2
  135. package/dist/engine/handler-field-references.js +55 -17
  136. package/dist/engine/index.d.ts +2 -2
  137. package/dist/engine/schema-builder.js +2 -1
  138. package/dist/engine/soft-delete-cleanup.js +10 -3
  139. package/dist/engine/steps/retry.js +2 -1
  140. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  141. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  142. package/dist/engine/template-variables.d.ts +3 -0
  143. package/dist/engine/template-variables.js +3 -0
  144. package/dist/engine/types/index.d.ts +1 -1
  145. package/dist/env/_zod-introspect.d.ts +1 -0
  146. package/dist/errors/classes.js +1 -0
  147. package/dist/errors/kumiko-error.d.ts +2 -0
  148. package/dist/errors/kumiko-error.js +2 -0
  149. package/dist/errors/write-error-info.js +2 -0
  150. package/dist/event-store/tenant-db-append.js +2 -2
  151. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  152. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  153. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  154. package/dist/files/bind-pending-file-refs.js +3 -3
  155. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  156. package/dist/files/bind-referenced-files.write.js +22 -13
  157. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  158. package/dist/files/create-file-ref-from-bytes.js +67 -4
  159. package/dist/files/create-file-ref.d.ts +22 -0
  160. package/dist/files/create-file-ref.js +53 -25
  161. package/dist/files/file-handle.d.ts +1 -1
  162. package/dist/files/file-handle.js +8 -0
  163. package/dist/files/file-routes.js +1 -1
  164. package/dist/files/index.d.ts +2 -2
  165. package/dist/files/index.js +1 -1
  166. package/dist/files/load-save-file-refs.d.ts +10 -0
  167. package/dist/files/load-save-file-refs.js +47 -0
  168. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  169. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  170. package/dist/jobs/index.d.ts +1 -1
  171. package/dist/jobs/job-runner.d.ts +4 -1
  172. package/dist/jobs/job-runner.js +8 -4
  173. package/dist/observability/escape-hatch-report.d.ts +1 -1
  174. package/dist/observability/escape-hatch-report.js +17 -5
  175. package/dist/pipeline/cascade-handler.js +5 -2
  176. package/dist/pipeline/dispatch-batch.js +79 -31
  177. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  178. package/dist/pipeline/dispatch-shared.js +99 -22
  179. package/dist/pipeline/dispatch-write.js +34 -22
  180. package/dist/pipeline/index.d.ts +1 -0
  181. package/dist/pipeline/index.js +1 -0
  182. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  183. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  184. package/dist/rate-limit/bucket.d.ts +3 -3
  185. package/dist/rate-limit/bucket.js +14 -4
  186. package/dist/redis/cache-sync-bus.js +9 -3
  187. package/dist/search/meilisearch-adapter.js +36 -9
  188. package/package.json +7 -5
  189. package/src/changes.json +207 -8
@@ -11,6 +11,7 @@ export type ErrorCtorInput = {
11
11
  readonly i18nParams?: Readonly<Record<string, unknown>>;
12
12
  readonly details?: unknown;
13
13
  readonly cause?: Error;
14
+ readonly retryAfterSeconds?: number;
14
15
  };
15
16
  export declare abstract class KumikoError extends Error {
16
17
  abstract readonly code: string;
@@ -18,6 +19,7 @@ export declare abstract class KumikoError extends Error {
18
19
  readonly i18nKey: string;
19
20
  readonly i18nParams: Readonly<Record<string, unknown>> | undefined;
20
21
  readonly details: unknown;
22
+ readonly retryAfterSeconds: number | undefined;
21
23
  constructor(input: ErrorCtorInput);
22
24
  }
23
25
  export declare function isKumikoError(e: unknown): e is KumikoError;
@@ -6,12 +6,14 @@ export class KumikoError extends Error {
6
6
  i18nKey;
7
7
  i18nParams;
8
8
  details;
9
+ retryAfterSeconds;
9
10
  constructor(input) {
10
11
  super(input.message, input.cause ? { cause: input.cause } : undefined);
11
12
  this.name = new.target.name;
12
13
  this.i18nKey = input.i18nKey;
13
14
  this.i18nParams = input.i18nParams;
14
15
  this.details = input.details;
16
+ this.retryAfterSeconds = input.retryAfterSeconds;
15
17
  }
16
18
  }
17
19
  export function isKumikoError(e) {
@@ -69,6 +69,7 @@ export function toWriteErrorInfo(err) {
69
69
  message: err.message,
70
70
  ...(err.i18nParams && { i18nParams: err.i18nParams }),
71
71
  ...(effectiveDetails !== undefined && { details: effectiveDetails }),
72
+ ...(err.retryAfterSeconds !== undefined && { retryAfterSeconds: err.retryAfterSeconds }),
72
73
  };
73
74
  if (err.cause instanceof Error)
74
75
  causeByWriteErrorInfo.set(info, err.cause);
@@ -91,6 +92,7 @@ class ReraisedError extends KumikoError {
91
92
  i18nKey: info.i18nKey,
92
93
  ...(info.i18nParams && { i18nParams: info.i18nParams }),
93
94
  ...(info.details !== undefined && { details: info.details }),
95
+ ...(info.retryAfterSeconds !== undefined && { retryAfterSeconds: info.retryAfterSeconds }),
94
96
  ...(cause && { cause }),
95
97
  });
96
98
  this.code = info.code;
@@ -1,11 +1,11 @@
1
- import { tenantDbRunner } from "../db/tenant-db-runner.js";
1
+ import { tenantDbRunner, tenantDbWriteRunner } from "../db/tenant-db-runner.js";
2
2
  import { runProjectionsForEvent } from "../pipeline/projections-runner.js";
3
3
  import { append, getStreamVersion } from "./event-store.js";
4
4
  // Appends on the connection or transaction the TenantDb is bound to. The tenant is always the
5
5
  // TenantDb's own, so a caller cannot write into another tenant's stream; a TenantDb the framework
6
6
  // did not build (or a fail-closed systemScope guard) is rejected by tenantDbRunner.
7
7
  export async function appendEventInTenantDb(tenantDb, event, options = {}) {
8
- const runner = tenantDbRunner(tenantDb);
8
+ const runner = tenantDbWriteRunner(tenantDb, "appendEventInTenantDb");
9
9
  const stored = await append(runner, { ...event, tenantId: tenantDb.tenantId });
10
10
  if (options.registry)
11
11
  await runProjectionsForEvent(stored, options.registry, runner);
@@ -1,8 +1,7 @@
1
- import type { TenantDb } from "../db/tenant-db.js";
2
- import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
1
+ import { type Registry, type SaveContext } from "../engine/types/index.js";
2
+ import type { FileRefRows } from "./load-save-file-refs.js";
3
3
  export declare function assertFileRefsBelongToTenant(args: {
4
4
  readonly registry: Registry;
5
5
  readonly result: SaveContext;
6
- readonly user: SessionUser;
7
- readonly db: TenantDb;
8
- }): Promise<void>;
6
+ readonly fileRefRows: FileRefRows;
7
+ }): void;
@@ -1,16 +1,17 @@
1
- import { isFileField, isUuid, } from "../engine/types/index.js";
1
+ import { isFileField } from "../engine/types/index.js";
2
2
  import { ValidationError } from "../errors/index.js";
3
3
  import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
- import { getFileRefExecutor } from "./create-file-ref.js";
5
4
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
5
  // A file field only stores an id, and the file routes authorize by tenant at
7
6
  // read time: without this check a writer could name another tenant's fileRef
8
7
  // and make the record point at it. Only newly written values are checked (all
9
8
  // of a create, `changes` of an update), so existing data is left alone. The
10
9
  // error is the same for "missing" and "belongs to another tenant" so the id
11
- // cannot be probed across tenants.
12
- export async function assertFileRefsBelongToTenant(args) {
13
- const { registry, result, user, db } = args;
10
+ // cannot be probed across tenants. `fileRefRows` holds only the writer's own
11
+ // tenant's live rows, so a missing entry is exactly those two cases. Values
12
+ // that are not UUIDs are skipped by fileRefIdsOf and never reach this check.
13
+ export function assertFileRefsBelongToTenant(args) {
14
+ const { registry, result, fileRefRows } = args;
14
15
  // skip: saves of unregistered aggregates (custom executors) declare no file fields
15
16
  if (result.entityName === undefined)
16
17
  return;
@@ -21,12 +22,11 @@ export async function assertFileRefsBelongToTenant(args) {
21
22
  // skip: without the files feature the file_refs table is not part of the schema
22
23
  if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
23
24
  return;
24
- const executor = getFileRefExecutor();
25
25
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
26
26
  if (!isFileField(fieldDef))
27
27
  continue;
28
28
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
29
- if (await isFileRefOfTenant(executor, fileRefId, user, db))
29
+ if (fileRefRows.has(fileRefId))
30
30
  continue;
31
31
  throw new ValidationError({
32
32
  fields: [
@@ -40,11 +40,3 @@ export async function assertFileRefsBelongToTenant(args) {
40
40
  }
41
41
  }
42
42
  }
43
- async function isFileRefOfTenant(executor, fileRefId, user, db) {
44
- if (!isUuid(fileRefId))
45
- return false;
46
- // detail() widens its tenant filter to (self, SYSTEM); only the writer's own
47
- // tenant counts here, same as GET /files/:id/meta.
48
- const row = await executor.detail({ id: fileRefId }, user, db);
49
- return row !== null && row["tenantId"] === user.tenantId;
50
- }
@@ -1,9 +1,11 @@
1
1
  import type { TenantDb } from "../db/tenant-db.js";
2
2
  import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
3
+ import type { FileRefRows } from "./load-save-file-refs.js";
3
4
  export declare function fileRefIdsOf(value: unknown): readonly string[];
4
5
  export declare function bindPendingFileRefs(args: {
5
6
  readonly registry: Registry;
6
7
  readonly result: SaveContext;
7
8
  readonly user: SessionUser;
8
9
  readonly db: TenantDb;
10
+ readonly fileRefRows: FileRefRows;
9
11
  }): Promise<void>;
@@ -23,7 +23,7 @@ function isPendingUploadFor(row, entityName, fieldName, user) {
23
23
  // someone else's upload id in a save payload must not pull that file into a
24
24
  // record the caller controls.
25
25
  export async function bindPendingFileRefs(args) {
26
- const { registry, result, user, db } = args;
26
+ const { registry, result, user, db, fileRefRows } = args;
27
27
  // skip: saves of unnamed aggregates declare no file fields
28
28
  if (result.entityName === undefined)
29
29
  return;
@@ -42,8 +42,8 @@ export async function bindPendingFileRefs(args) {
42
42
  // changes, not data: an update's row carries every stored file id, including
43
43
  // ones bound earlier; only the ids this save names can be pending uploads.
44
44
  for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
45
- const row = await executor.detail({ id: fileRefId }, user, db);
46
- if (row === null || !isPendingUploadFor(row, entityName, fieldName, user))
45
+ const row = fileRefRows.get(fileRefId);
46
+ if (row === undefined || !isPendingUploadFor(row, entityName, fieldName, user))
47
47
  continue;
48
48
  const version = typeof row["version"] === "number" ? row["version"] : undefined;
49
49
  const bound = await executor.update({ id: fileRefId, version, changes: { entityId: String(result.id) } }, user, db);
@@ -13,4 +13,4 @@ export declare const bindReferencedFilesWrite: import("@cosmicdrift/kumiko-types
13
13
  bound: number;
14
14
  skipped: number;
15
15
  nextAfterId: string | null;
16
- }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
16
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
@@ -5,6 +5,7 @@ import { ValidationError } from "../errors/index.js";
5
5
  import { fileRefIdsOf } from "./bind-pending-file-refs.js";
6
6
  import { getFileRefExecutor } from "./create-file-ref.js";
7
7
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
8
+ import { loadFileRefRows } from "./load-save-file-refs.js";
8
9
  import { findImplicitEntityTable } from "./record-write-check.js";
9
10
  const MAX_PAGE_SIZE = 500;
10
11
  const DEFAULT_PAGE_SIZE = 200;
@@ -50,40 +51,48 @@ function loadRecordPage(db, { entity, table }, { entityIds, afterId, limit }) {
50
51
  : afterId !== undefined && { id: { gt: afterId } }),
51
52
  }, { orderBy: { col: "id", direction: "asc" }, ...(entityIds === undefined && { limit }) });
52
53
  }
53
- // Unbound, same tenant, uploaded by the record's creator (or the system), and either
54
- // unclaimed or pending for exactly this entity field.
54
+ // Unbound and same tenant. The record's creator may bind their own unclaimed or pending upload.
55
+ // A system-generated file carries no owner of its own, so it binds only where it is already
56
+ // pending for exactly this entity field — an unclaimed one could otherwise be pulled into any
57
+ // record that merely names its id.
55
58
  function isBindableToRecord(fileRef, target) {
56
59
  const uploader = fileRef["insertedById"];
57
- return (typeof uploader === "string" &&
58
- (uploader === target.record["insertedById"] || uploader === SYSTEM_USER_ID) &&
59
- fileRef["tenantId"] === target.tenantId &&
60
- fileRef["entityId"] === null &&
61
- (fileRef["entityType"] === null ||
62
- (fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName)));
60
+ if (typeof uploader !== "string" ||
61
+ fileRef["tenantId"] !== target.tenantId ||
62
+ fileRef["entityId"] !== null) {
63
+ return false;
64
+ }
65
+ const isPendingForTarget = fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName;
66
+ if (uploader === target.record["insertedById"]) {
67
+ return fileRef["entityType"] === null || isPendingForTarget;
68
+ }
69
+ return uploader === SYSTEM_USER_ID && isPendingForTarget;
63
70
  }
64
71
  // The binding follows only the record's declared file fields; the caller names
65
72
  // no file ids. The record's creator must have uploaded the file themselves (or the
66
- // system generated it), otherwise a foreign reference would pull someone else's
67
- // private upload into the referencing user's record and make it readable there.
73
+ // system generated it for exactly this field), otherwise a foreign reference would pull
74
+ // someone else's private upload into the referencing user's record and make it readable there.
68
75
  export const bindReferencedFilesWrite = defineWriteHandler({
69
76
  name: "bindReferencedFiles",
70
77
  schema: payloadSchema,
71
78
  access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
72
79
  // Maintenance backfill run by ops or system jobs, not a task an assistant performs.
73
80
  agent: { expose: false },
74
- description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
81
+ description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system for exactly this field; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
75
82
  handler: async (event, ctx) => {
76
83
  const { entityType, entityIds, limit } = event.payload;
77
84
  const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
78
85
  const records = await loadRecordPage(ctx.db, source, event.payload);
86
+ const referencedIds = records.flatMap((record) => fileFieldNames.flatMap((fieldName) => fileRefIdsOf(record[fieldName])));
87
+ const fileRefRows = await loadFileRefRows(ctx.db, referencedIds, event.user.tenantId);
79
88
  const executor = getFileRefExecutor();
80
89
  let bound = 0;
81
90
  let skipped = 0;
82
91
  for (const record of records) {
83
92
  for (const fieldName of fileFieldNames) {
84
93
  for (const fileRefId of fileRefIdsOf(record[fieldName])) {
85
- const row = await executor.detail({ id: fileRefId }, event.user, ctx.db);
86
- if (row === null ||
94
+ const row = fileRefRows.get(fileRefId);
95
+ if (row === undefined ||
87
96
  !isBindableToRecord(row, {
88
97
  record,
89
98
  entityType,
@@ -1,8 +1,8 @@
1
1
  import type { TenantDb } from "../db/tenant-db.js";
2
- import type { Registry, SessionUser, WriteResult } from "../engine/types/index.js";
3
- import { type CreatedFileRef } from "./create-file-ref.js";
2
+ import type { BeforeTransactionResult, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
3
+ import { type CreatedFileRef, type UploadedFileBytes } from "./create-file-ref.js";
4
4
  import type { FileContext } from "./file-handle.js";
5
- export type { CreatedFileRef };
5
+ export type { CreatedFileRef, UploadedFileBytes };
6
6
  export type CreateFileRefContext = {
7
7
  readonly db: TenantDb;
8
8
  readonly files?: FileContext | undefined;
@@ -20,3 +20,6 @@ export type CreateFileRefInput = {
20
20
  };
21
21
  };
22
22
  export declare function createFileRefFromBytes(ctx: CreateFileRefContext, input: CreateFileRefInput): Promise<WriteResult<CreatedFileRef>>;
23
+ export type UploadFileBytesContext = Pick<CreateFileRefContext, "db" | "files" | "registry" | "user">;
24
+ export declare function uploadFileBytesBeforeTransaction(ctx: UploadFileBytesContext, input: CreateFileRefInput): Promise<BeforeTransactionResult<UploadedFileBytes>>;
25
+ export declare function createFileRefFromUploadedBytes(ctx: Pick<CreateFileRefContext, "db" | "user">, uploaded: UploadedFileBytes): Promise<WriteResult<CreatedFileRef>>;
@@ -1,6 +1,9 @@
1
+ import { fetchOne } from "../bun-db/query.js";
1
2
  import { FrameworkReasons, InternalError, UnprocessableError, writeFailure, } from "../errors/index.js";
2
- import { DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, } from "./create-file-ref.js";
3
+ import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcceptedFileBytes, } from "./create-file-ref.js";
4
+ import { fileRefsTable } from "./file-ref-table.js";
3
5
  import { readFilesRouteOptions } from "./file-routes.js";
6
+ import { assertSafeStorageKey } from "./types.js";
4
7
  // Server-side counterpart of POST /files for handlers/jobs that produce bytes
5
8
  // themselves (generated PDFs, exports). Same validation, storage layout and
6
9
  // fileRef.created event as an upload; the tenant is always ctx.user.tenantId.
@@ -30,10 +33,70 @@ export async function createFileRefFromBytes(ctx, input) {
30
33
  case "created":
31
34
  return { isSuccess: true, data: outcome.fileRef };
32
35
  case "rejected":
33
- return writeFailure(new UnprocessableError(FrameworkReasons.fileRejected, {
34
- details: { error: outcome.error },
35
- }));
36
+ return fileRejectedFailure(outcome.error);
36
37
  case "failed":
37
38
  return outcome.failure;
38
39
  }
39
40
  }
41
+ function fileRejectedFailure(error) {
42
+ return writeFailure(new UnprocessableError(FrameworkReasons.fileRejected, { details: { error } }));
43
+ }
44
+ // Pre-transaction half of createFileRefFromBytes: validates and writes the binary while no
45
+ // transaction holds a connection. Pair it with createFileRefFromUploadedBytes inside the handler.
46
+ // A crash between this phase and the commit leaves the bytes in storage; nothing sweeps them.
47
+ export async function uploadFileBytesBeforeTransaction(ctx, input) {
48
+ const { files } = ctx;
49
+ if (!files) {
50
+ return writeFailure(new InternalError({
51
+ message: "uploadFileBytesBeforeTransaction: ctx.files is unavailable — mount a file-provider feature to store files",
52
+ }));
53
+ }
54
+ const outcome = await writeAcceptedFileBytes({
55
+ user: ctx.user,
56
+ registry: ctx.registry,
57
+ defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
58
+ writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
59
+ }, {
60
+ fileName: input.fileName,
61
+ declaredMimeType: input.mimeType,
62
+ data: input.data,
63
+ entityType: input.attachTo?.entityType,
64
+ entityId: input.attachTo?.entityId,
65
+ fieldName: input.attachTo?.fieldName,
66
+ });
67
+ if (outcome.kind === "rejected")
68
+ return fileRejectedFailure(outcome.error);
69
+ const { uploaded } = outcome;
70
+ return {
71
+ isSuccess: true,
72
+ data: uploaded,
73
+ // Also runs after a failed COMMIT whose outcome is unknown: a committed row must keep its binary.
74
+ release: async () => {
75
+ const committedRow = await fetchOne(ctx.db, fileRefsTable, {
76
+ storageKey: uploaded.storageKey,
77
+ });
78
+ if (!committedRow)
79
+ await files.ref(uploaded.storageKey).delete();
80
+ },
81
+ };
82
+ }
83
+ // Transaction half: creates the fileRef row for bytes uploadFileBytesBeforeTransaction wrote.
84
+ export async function createFileRefFromUploadedBytes(ctx, uploaded) {
85
+ const ownPrefix = `${ctx.user.tenantId}/`;
86
+ if (!uploaded.storageKey.startsWith(ownPrefix)) {
87
+ return writeFailure(new InternalError({
88
+ message: "createFileRefFromUploadedBytes: storageKey is outside the caller's tenant prefix",
89
+ }));
90
+ }
91
+ try {
92
+ assertSafeStorageKey(uploaded.storageKey);
93
+ }
94
+ catch (error) {
95
+ return writeFailure(new InternalError({
96
+ message: "createFileRefFromUploadedBytes: unsafe storageKey",
97
+ ...(error instanceof Error && { cause: error }),
98
+ }));
99
+ }
100
+ const row = await createFileRefRow({ user: ctx.user, tenantDb: ctx.db }, uploaded);
101
+ return "failure" in row ? row.failure : { isSuccess: true, data: row.fileRef };
102
+ }
@@ -36,6 +36,22 @@ export type CreatedFileRef = {
36
36
  readonly size: number;
37
37
  readonly storageKey: string;
38
38
  };
39
+ export type UploadedFileBytes = {
40
+ readonly storageKey: string;
41
+ readonly fileName: string;
42
+ readonly mimeType: string;
43
+ readonly size: number;
44
+ readonly entityType?: string | undefined;
45
+ readonly entityId?: string | undefined;
46
+ readonly fieldName?: string | undefined;
47
+ };
48
+ export type UploadBytesOutcome = {
49
+ readonly kind: "uploaded";
50
+ readonly uploaded: UploadedFileBytes;
51
+ } | {
52
+ readonly kind: "rejected";
53
+ readonly error: string;
54
+ };
39
55
  export type StoreFileOutcome = {
40
56
  readonly kind: "created";
41
57
  readonly fileRef: CreatedFileRef;
@@ -46,5 +62,11 @@ export type StoreFileOutcome = {
46
62
  readonly kind: "failed";
47
63
  readonly failure: WriteFailure;
48
64
  };
65
+ export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
66
+ export declare function createFileRefRow(deps: Pick<StoreFileDeps, "user" | "tenantDb">, uploaded: UploadedFileBytes): Promise<{
67
+ readonly fileRef: CreatedFileRef;
68
+ } | {
69
+ readonly failure: WriteFailure;
70
+ }>;
49
71
  export declare function storeFileAsFileRef(deps: StoreFileDeps, input: StoreFileInput): Promise<StoreFileOutcome>;
50
72
  export {};
@@ -92,44 +92,72 @@ function acceptFile(deps, input) {
92
92
  extension: undefined,
93
93
  };
94
94
  }
95
- // The one upload pipeline behind POST /files and createFileRefFromBytes.
96
- export async function storeFileAsFileRef(deps, input) {
95
+ // Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
96
+ // I/O doesn't belong inside a PG connection's tx window.
97
+ export async function writeAcceptedFileBytes(deps, input) {
97
98
  const { fileName, data, entityType, entityId, fieldName } = input;
98
99
  const accepted = acceptFile(deps, input);
99
100
  if (accepted.kind === "rejected")
100
101
  return accepted;
101
102
  const { mimeType, extension } = accepted;
102
- const fileRefId = generateId();
103
103
  const storageKey = buildStorageKey(deps.user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", fileName, generateId(), extension);
104
- // Write binary FIRST (outside the tx — network/disk I/O doesn't belong
105
- // inside a PG connection's tx window). A failed create deletes the bytes
106
- // again below; a later rollback of an enclosing transaction cannot, so
107
- // those are orphaned for the cleanup-jobs to sweep. Losing a row on
108
- // append-failure is acceptable; a committed row with a missing binary is not.
109
104
  await deps.writeBytes(storageKey, data, mimeType);
110
- // id is explicit so the response + storageKey stay consistent; tenantId,
111
- // insertedAt and insertedById are set by applyEntityEvent from the event metadata.
105
+ return {
106
+ kind: "uploaded",
107
+ uploaded: {
108
+ storageKey,
109
+ fileName,
110
+ mimeType,
111
+ size: data.byteLength,
112
+ entityType,
113
+ entityId,
114
+ fieldName,
115
+ },
116
+ };
117
+ }
118
+ // Row step: id is explicit so the response + storageKey stay consistent; tenantId, insertedAt and
119
+ // insertedById are set by applyEntityEvent from the event metadata.
120
+ export async function createFileRefRow(deps, uploaded) {
121
+ const fileRefId = generateId();
112
122
  const result = await getFileRefExecutor().create({
113
123
  id: fileRefId,
114
- storageKey,
115
- fileName,
116
- mimeType,
117
- size: data.byteLength,
118
- entityType: entityType ?? null,
119
- entityId: entityId ?? null,
120
- fieldName: fieldName ?? null,
124
+ storageKey: uploaded.storageKey,
125
+ fileName: uploaded.fileName,
126
+ mimeType: uploaded.mimeType,
127
+ size: uploaded.size,
128
+ entityType: uploaded.entityType ?? null,
129
+ entityId: uploaded.entityId ?? null,
130
+ fieldName: uploaded.fieldName ?? null,
121
131
  }, deps.user, deps.tenantDb);
122
- if (!result.isSuccess) {
132
+ if (!result.isSuccess)
133
+ return { failure: result };
134
+ return {
135
+ fileRef: {
136
+ id: fileRefId,
137
+ fileName: uploaded.fileName,
138
+ mimeType: uploaded.mimeType,
139
+ size: uploaded.size,
140
+ storageKey: uploaded.storageKey,
141
+ },
142
+ };
143
+ }
144
+ // The one upload pipeline behind POST /files and createFileRefFromBytes.
145
+ export async function storeFileAsFileRef(deps, input) {
146
+ const upload = await writeAcceptedFileBytes(deps, input);
147
+ if (upload.kind === "rejected")
148
+ return upload;
149
+ // A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
150
+ // crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
151
+ // Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
152
+ const row = await createFileRefRow(deps, upload.uploaded);
153
+ if ("failure" in row) {
123
154
  try {
124
- await deps.deleteBytes(storageKey);
155
+ await deps.deleteBytes(upload.uploaded.storageKey);
125
156
  }
126
157
  catch {
127
- // skip: the create failure is the error worth reporting; leftover bytes are swept by cleanup-jobs.
158
+ // skip: the create failure is the error worth reporting; the leftover bytes stay in storage.
128
159
  }
129
- return { kind: "failed", failure: result };
160
+ return { kind: "failed", failure: row.failure };
130
161
  }
131
- return {
132
- kind: "created",
133
- fileRef: { id: fileRefId, fileName, mimeType, size: data.byteLength, storageKey },
134
- };
162
+ return { kind: "created", fileRef: row.fileRef };
135
163
  }
@@ -1,5 +1,5 @@
1
1
  import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
2
- import type { FileStorageProvider } from "./types.js";
2
+ import { type FileStorageProvider } from "./types.js";
3
3
  export type { FileContext, FileHandle };
4
4
  export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
5
5
  export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
@@ -11,6 +11,7 @@
11
11
  // `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
12
12
  // the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
13
13
  import { isDerivativeSuffix } from "../derivatives/variant-key.js";
14
+ import { assertSafeStorageKey } from "./types.js";
14
15
  // `getProvider` is a lazily-resolved, memoized accessor — the provider is
15
16
  // resolved (config + s3.secretAccessKey secret read) only when a handle method
16
17
  // actually does I/O, never on every request that merely builds a ctx.
@@ -54,6 +55,13 @@ export function createFileContext(resolve, tenantId) {
54
55
  }
55
56
  return createFileHandle(key, getProvider);
56
57
  },
58
+ /** Validates only the storage key shape (assertSafeStorageKey), no tenant prefix: keys
59
+ * may outlive a tenant handover. The caller must ensure the fileRef row came from a
60
+ * tenant-filtered read. */
61
+ refFromFileRef: (row) => {
62
+ assertSafeStorageKey(row.storageKey);
63
+ return createFileHandle(row.storageKey, getProvider);
64
+ },
57
65
  list: async (prefix) => {
58
66
  if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
59
67
  throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
@@ -261,7 +261,7 @@ export function createFileRoutes(options) {
261
261
  tenantId: user.tenantId,
262
262
  });
263
263
  const result = await derivatives.variant(id, spec, name);
264
- const data = await files.ref(result.storageKey).read();
264
+ const data = await files.refFromFileRef(result).read();
265
265
  // No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
266
266
  // size, and a variant is rendered for display, not for download.
267
267
  // storageKey embeds specHash(spec), so the URL is content-stable until
@@ -1,5 +1,5 @@
1
- export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, } from "./create-file-ref-from-bytes.js";
2
- export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
1
+ export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.js";
2
+ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
3
3
  export { createFilesFeature } from "./feature.js";
4
4
  export type { FileContext, FileHandle } from "./file-handle.js";
5
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
@@ -1,4 +1,4 @@
1
- export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
1
+ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
2
2
  export { createFilesFeature } from "./feature.js";
3
3
  // `createFileHandle` is an implementation detail — construct handles via
4
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
@@ -0,0 +1,10 @@
1
+ import type { TenantDb } from "../db/tenant-db.js";
2
+ import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
3
+ export type FileRefRows = ReadonlyMap<string, Readonly<Record<string, unknown>>>;
4
+ export declare function loadSaveFileRefs(args: {
5
+ readonly registry: Registry;
6
+ readonly result: SaveContext;
7
+ readonly user: SessionUser;
8
+ readonly db: TenantDb;
9
+ }): Promise<FileRefRows>;
10
+ export declare function loadFileRefRows(db: TenantDb, ids: readonly string[], tenantId: SessionUser["tenantId"]): Promise<FileRefRows>;
@@ -0,0 +1,47 @@
1
+ import { selectMany } from "../db/query.js";
2
+ import { tenantDbRunner } from "../db/tenant-db-runner.js";
3
+ import { isFileField, } from "../engine/types/index.js";
4
+ import { getStreamVersions } from "../event-store/event-store.js";
5
+ import { fileRefIdsOf } from "./bind-pending-file-refs.js";
6
+ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
7
+ import { fileRefsTable } from "./file-ref-table.js";
8
+ // One query for every fileRef a save names, restricted to the writer's own tenant and live
9
+ // rows: the tenant check and the pending-upload binding both work from this map instead of
10
+ // reading each row again inside the write transaction.
11
+ export async function loadSaveFileRefs(args) {
12
+ const { registry, result, user, db } = args;
13
+ const entity = result.entityName === undefined ? undefined : registry.getEntity(result.entityName);
14
+ // skip: saves of unregistered or unnamed aggregates declare no file fields
15
+ if (entity === undefined)
16
+ return new Map();
17
+ // skip: without the files feature the file_refs table is not part of the schema
18
+ if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
19
+ return new Map();
20
+ const ids = new Set();
21
+ for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
22
+ if (!isFileField(fieldDef))
23
+ continue;
24
+ for (const id of fileRefIdsOf(result.changes[fieldName]))
25
+ ids.add(id);
26
+ }
27
+ return loadFileRefRows(db, [...ids], user.tenantId);
28
+ }
29
+ // One query for the given live fileRefs of one tenant. Each row carries the stream version as
30
+ // `version`: the binding update needs it as expectedVersion, and events without a projection
31
+ // effect (e.g. ingest requests) advance the stream but not the row's version column.
32
+ export async function loadFileRefRows(db, ids, tenantId) {
33
+ // skip: nothing referenced, nothing to load
34
+ if (ids.length === 0)
35
+ return new Map();
36
+ const rows = await selectMany(db, fileRefsTable, {
37
+ id: { in: [...ids] },
38
+ tenantId,
39
+ isDeleted: false,
40
+ });
41
+ const streamVersions = await getStreamVersions(tenantDbRunner(db), rows.map((row) => String(row["id"])), db.tenantId);
42
+ return new Map(rows.map((row) => {
43
+ const id = String(row["id"]);
44
+ const streamVersion = streamVersions.get(id);
45
+ return [id, streamVersion === undefined ? row : { ...row, version: streamVersion }];
46
+ }));
47
+ }
@@ -4,11 +4,14 @@ import { deleteMany, type WhereObject } from "../db/query.js";
4
4
  import type { TenantDb } from "../db/tenant-db.js";
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
8
  export declare function purgeExpiredFileRefsWithBytes(args: {
8
9
  readonly db: TenantDb;
9
- readonly table: Parameters<typeof deleteMany>[1];
10
+ readonly table: PurgeTable;
10
11
  readonly where: WhereObject;
12
+ readonly expiredBefore: Temporal.Instant;
11
13
  readonly files: FileContext | undefined;
12
14
  readonly log: PurgeLog;
15
+ readonly pageSize?: number;
13
16
  }): Promise<void>;
14
17
  export {};