@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -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 +319 -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 +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import type { TenantDb } from "../db/tenant-db.js";
1
+ import { type TenantDb } from "../db/tenant-db.js";
2
2
  import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.js";
3
3
  import type { WriteFailure } from "../errors/index.js";
4
4
  export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
@@ -24,6 +24,7 @@ export type StoreFileInput = {
24
24
  export type StoreFileDeps = {
25
25
  readonly user: SessionUser;
26
26
  readonly tenantDb: TenantDb;
27
+ readonly rowInOwnTransaction?: boolean;
27
28
  readonly registry: Registry | undefined;
28
29
  readonly defaultMaxUploadSize: string;
29
30
  readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
@@ -36,6 +37,22 @@ export type CreatedFileRef = {
36
37
  readonly size: number;
37
38
  readonly storageKey: string;
38
39
  };
40
+ export type UploadedFileBytes = {
41
+ readonly storageKey: string;
42
+ readonly fileName: string;
43
+ readonly mimeType: string;
44
+ readonly size: number;
45
+ readonly entityType?: string | undefined;
46
+ readonly entityId?: string | undefined;
47
+ readonly fieldName?: string | undefined;
48
+ };
49
+ export type UploadBytesOutcome = {
50
+ readonly kind: "uploaded";
51
+ readonly uploaded: UploadedFileBytes;
52
+ } | {
53
+ readonly kind: "rejected";
54
+ readonly error: string;
55
+ };
39
56
  export type StoreFileOutcome = {
40
57
  readonly kind: "created";
41
58
  readonly fileRef: CreatedFileRef;
@@ -46,5 +63,11 @@ export type StoreFileOutcome = {
46
63
  readonly kind: "failed";
47
64
  readonly failure: WriteFailure;
48
65
  };
66
+ export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
67
+ export declare function createFileRefRow(deps: Pick<StoreFileDeps, "user" | "tenantDb">, uploaded: UploadedFileBytes): Promise<{
68
+ readonly fileRef: CreatedFileRef;
69
+ } | {
70
+ readonly failure: WriteFailure;
71
+ }>;
49
72
  export declare function storeFileAsFileRef(deps: StoreFileDeps, input: StoreFileInput): Promise<StoreFileOutcome>;
50
73
  export {};
@@ -1,4 +1,5 @@
1
1
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
2
+ import { runInOwnTransaction } from "../db/tenant-db.js";
2
3
  import { isFileField, } from "../engine/types/index.js";
3
4
  import { generateId } from "../utils/index.js";
4
5
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
@@ -92,44 +93,85 @@ function acceptFile(deps, input) {
92
93
  extension: undefined,
93
94
  };
94
95
  }
95
- // The one upload pipeline behind POST /files and createFileRefFromBytes.
96
- export async function storeFileAsFileRef(deps, input) {
96
+ // Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
97
+ // I/O doesn't belong inside a PG connection's tx window.
98
+ export async function writeAcceptedFileBytes(deps, input) {
97
99
  const { fileName, data, entityType, entityId, fieldName } = input;
98
100
  const accepted = acceptFile(deps, input);
99
101
  if (accepted.kind === "rejected")
100
102
  return accepted;
101
103
  const { mimeType, extension } = accepted;
102
- const fileRefId = generateId();
103
104
  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
105
  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.
106
+ return {
107
+ kind: "uploaded",
108
+ uploaded: {
109
+ storageKey,
110
+ fileName,
111
+ mimeType,
112
+ size: data.byteLength,
113
+ entityType,
114
+ entityId,
115
+ fieldName,
116
+ },
117
+ };
118
+ }
119
+ // Row step: id is explicit so the response + storageKey stay consistent; tenantId, insertedAt and
120
+ // insertedById are set by applyEntityEvent from the event metadata.
121
+ export async function createFileRefRow(deps, uploaded) {
122
+ const fileRefId = generateId();
112
123
  const result = await getFileRefExecutor().create({
113
124
  id: fileRefId,
114
- storageKey,
115
- fileName,
116
- mimeType,
117
- size: data.byteLength,
118
- entityType: entityType ?? null,
119
- entityId: entityId ?? null,
120
- fieldName: fieldName ?? null,
125
+ storageKey: uploaded.storageKey,
126
+ fileName: uploaded.fileName,
127
+ mimeType: uploaded.mimeType,
128
+ size: uploaded.size,
129
+ entityType: uploaded.entityType ?? null,
130
+ entityId: uploaded.entityId ?? null,
131
+ fieldName: uploaded.fieldName ?? null,
121
132
  }, deps.user, deps.tenantDb);
122
- if (!result.isSuccess) {
133
+ if (!result.isSuccess)
134
+ return { failure: result };
135
+ return {
136
+ fileRef: {
137
+ id: fileRefId,
138
+ fileName: uploaded.fileName,
139
+ mimeType: uploaded.mimeType,
140
+ size: uploaded.size,
141
+ storageKey: uploaded.storageKey,
142
+ },
143
+ };
144
+ }
145
+ // The one upload pipeline behind POST /files and createFileRefFromBytes.
146
+ export async function storeFileAsFileRef(deps, input) {
147
+ const upload = await writeAcceptedFileBytes(deps, input);
148
+ if (upload.kind === "rejected")
149
+ return upload;
150
+ // A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
151
+ // crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
152
+ // Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
153
+ // A throwing projection on fileRef.created fails the create by exception, not by WriteFailure.
154
+ const deleteUploadedBytes = async () => {
123
155
  try {
124
- await deps.deleteBytes(storageKey);
156
+ await deps.deleteBytes(upload.uploaded.storageKey);
125
157
  }
126
158
  catch {
127
- // skip: the create failure is the error worth reporting; leftover bytes are swept by cleanup-jobs.
159
+ // skip: the create failure is the error worth reporting; the leftover bytes stay in storage.
128
160
  }
129
- return { kind: "failed", failure: result };
130
- }
131
- return {
132
- kind: "created",
133
- fileRef: { id: fileRefId, fileName, mimeType, size: data.byteLength, storageKey },
134
161
  };
162
+ let row;
163
+ try {
164
+ row = deps.rowInOwnTransaction
165
+ ? await runInOwnTransaction(deps.tenantDb, (txDb) => createFileRefRow({ user: deps.user, tenantDb: txDb }, upload.uploaded))
166
+ : await createFileRefRow(deps, upload.uploaded);
167
+ }
168
+ catch (e) {
169
+ await deleteUploadedBytes();
170
+ throw e;
171
+ }
172
+ if ("failure" in row) {
173
+ await deleteUploadedBytes();
174
+ return { kind: "failed", failure: row.failure };
175
+ }
176
+ return { kind: "created", fileRef: row.fileRef };
135
177
  }
@@ -3,16 +3,31 @@ import { reraiseAsKumikoError } from "../errors/index.js";
3
3
  import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
4
  import { getFileRefExecutor } from "./create-file-ref.js";
5
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
- function isBoundTo(row, entityName, entityId, fieldName) {
6
+ // The bound fieldName may differ from the field the id was removed from: an id
7
+ // moved A -> B stays bound to A (binding never re-targets).
8
+ function isBoundToRecord(row, entityName, entityId, fileFields) {
9
+ const boundField = row["fieldName"];
7
10
  return (row["entityType"] === entityName &&
8
11
  row["entityId"] === entityId &&
9
- row["fieldName"] === fieldName);
12
+ typeof boundField === "string" &&
13
+ isFileField(fileFields[boundField]));
14
+ }
15
+ // Across all file fields: an id moved from one field to another is still in use.
16
+ function fileRefIdsStoredInAnyField(fields, data) {
17
+ const ids = new Set();
18
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
19
+ if (!isFileField(fieldDef))
20
+ continue;
21
+ for (const fileRefId of fileRefIdsOf(data[fieldName]))
22
+ ids.add(fileRefId);
23
+ }
24
+ return ids;
10
25
  }
11
26
  // An update that drops or replaces a file id leaves the fileRef readable to
12
27
  // everyone who may read the record. Deleting it goes through the same executor
13
28
  // path as DELETE /files/:id (soft delete, bytes kept until retention), so
14
29
  // record readers get 404 afterwards. Only fileRefs bound to exactly this
15
- // record and field are touched: an id that is pending, shared or bound
30
+ // record are touched: an id that is pending, shared or bound
16
31
  // elsewhere is not this save's to dispose. The authority is the caller's write
17
32
  // right on the record (the fileRef entity declares no write access of its own).
18
33
  export async function disposeRemovedFileRefs(args) {
@@ -33,15 +48,15 @@ export async function disposeRemovedFileRefs(args) {
33
48
  return;
34
49
  const entityId = String(result.id);
35
50
  const executor = getFileRefExecutor();
51
+ const keptAnywhere = fileRefIdsStoredInAnyField(entity.fields, result.data);
36
52
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
37
53
  if (!isFileField(fieldDef) || !(fieldName in result.changes))
38
54
  continue;
39
- const kept = new Set(fileRefIdsOf(result.data[fieldName]));
40
55
  for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
41
- if (kept.has(fileRefId))
56
+ if (keptAnywhere.has(fileRefId))
42
57
  continue;
43
58
  const row = await executor.detail({ id: fileRefId }, user, db);
44
- if (row === null || !isBoundTo(row, entityName, entityId, fieldName))
59
+ if (row === null || !isBoundToRecord(row, entityName, entityId, entity.fields))
45
60
  continue;
46
61
  const removed = await executor.delete({ id: fileRefId }, user, db);
47
62
  if (!removed.isSuccess)
@@ -7,4 +7,5 @@ export type FilesFeatureOptions = {
7
7
  readonly unclaimedUploadReaders?: readonly string[];
8
8
  readonly maxUploadSize?: string;
9
9
  };
10
+ export declare const FILES_FEATURE: "files";
10
11
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -13,8 +13,9 @@ export { fileRefEntity } from "./file-ref-entity.js";
13
13
  // userData/tenantData-Extensions sichtbar und registriert die implizite
14
14
  // Entity-Projektion (für rebuildProjection). Liegt im Framework neben
15
15
  // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
16
+ export const FILES_FEATURE = "files";
16
17
  export function createFilesFeature(options = {}) {
17
- return defineFeature("files", (r) => {
18
+ return defineFeature(FILES_FEATURE, (r) => {
18
19
  r.describe("Exposes the `fileRef` entity from the framework core so that uploaded files — tracked in the `file_refs` table by `createFileRoutes` — participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and tenant-lifecycle cleanup deletes all refs on tenant destroy. Upload/download routes are registered by the server bootstrap when a `file-provider-*` feature is mounted; this feature carries their access/size policy via `createFilesFeature(opts?)`.");
19
20
  r.uiHints({
20
21
  displayLabel: "Files · Metadata",
@@ -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,17 @@ 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
+ },
65
+ listFromFileRef: async (row) => {
66
+ assertSafeStorageKey(row.storageKey);
67
+ return (await getProvider()).list(storageKeyStemPrefix(row.storageKey));
68
+ },
57
69
  list: async (prefix) => {
58
70
  if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
59
71
  throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
@@ -4,8 +4,9 @@ import { getUser } from "../api/auth-middleware.js";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { selectMany } from "../bun-db/index.js";
6
6
  import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
7
- import { createTenantDb } from "../db/tenant-db.js";
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
10
  import { isFileField, isUuid, } from "../engine/types/index.js";
10
11
  import { buildContentDispositionHeader } from "./content-disposition.js";
11
12
  import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
@@ -32,8 +33,11 @@ function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReade
32
33
  return "allow";
33
34
  if (privilegedRoles.some((role) => user.roles.includes(role)))
34
35
  return "allow";
35
- const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
36
- if (isUnclaimed &&
36
+ // System-generated documents are created without attachTo too, but belong to a person's data, not to an inbox.
37
+ const isUnclaimedUpload = fileRef.entityType === null &&
38
+ fileRef.entityId === null &&
39
+ fileRef.insertedById !== SYSTEM_USER_ID;
40
+ if (isUnclaimedUpload &&
37
41
  operation === "read" &&
38
42
  unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
39
43
  return "allow";
@@ -88,8 +92,7 @@ export function createFileRoutes(options) {
88
92
  const executor = getFileRefExecutor();
89
93
  const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
90
94
  const api = new Hono();
91
- const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
92
- const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
95
+ const tenantDbFor = (tenantId) => createProjectingTenantDb(db, tenantId, options.registry);
93
96
  const fileNameIsPersonal = piiSubjectFields.includes("fileName");
94
97
  function kmsContextFor() {
95
98
  return { requestId: requestContext.get()?.requestId ?? "file-routes" };
@@ -171,6 +174,7 @@ export function createFileRoutes(options) {
171
174
  const outcome = await storeFileAsFileRef({
172
175
  user,
173
176
  tenantDb: tenantDbFor(user.tenantId),
177
+ rowInOwnTransaction: true,
174
178
  registry: options.registry,
175
179
  defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
176
180
  writeBytes: (key, bytes, mimeType) => storageProvider.write(key, bytes, mimeType),
@@ -261,7 +265,7 @@ export function createFileRoutes(options) {
261
265
  tenantId: user.tenantId,
262
266
  });
263
267
  const result = await derivatives.variant(id, spec, name);
264
- const data = await files.ref(result.storageKey).read();
268
+ const data = await files.refFromFileRef(result).read();
265
269
  // No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
266
270
  // size, and a variant is rendered for display, not for download.
267
271
  // storageKey embeds specHash(spec), so the URL is content-stable until
@@ -1,6 +1,6 @@
1
- export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, } from "./create-file-ref-from-bytes.js";
2
- export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
3
- export { createFilesFeature } from "./feature.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
+ export { createFilesFeature, FILES_FEATURE } from "./feature.js";
4
4
  export type { FileContext, FileHandle } from "./file-handle.js";
5
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
6
6
  export { fileRefEntity } from "./file-ref-entity.js";
@@ -1,5 +1,5 @@
1
- export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
2
- export { createFilesFeature } from "./feature.js";
1
+ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
2
+ export { createFilesFeature, FILES_FEATURE } from "./feature.js";
3
3
  // `createFileHandle` is an implementation detail — construct handles via
4
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
5
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
@@ -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 {};