@cosmicdrift/kumiko-framework 0.362.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 (259) 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 +14 -5
  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/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -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}/"`);
@@ -74,7 +74,7 @@ export function readFilesRouteOptions(registry) {
74
74
  if (exp && typeof exp === "object" && "routeOptions" in exp) {
75
75
  const ro = exp.routeOptions;
76
76
  if (ro && typeof ro === "object") {
77
- // @cast-boundary feature-exports: engine-payload (unknown) → known shape
77
+ // @cast-boundary feature-exports — typeof-checked read-back from untyped feature.exports
78
78
  return ro;
79
79
  }
80
80
  }
@@ -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
+ }
@@ -2,6 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
2
2
  import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
5
6
  import { assertSafeStorageKey } from "./types.js";
6
7
  // Local-filesystem backend — intended for dev + tests. Production deploys
7
8
  // pick an object-store provider (S3/R2/…). mimeType is ignored here; the
@@ -112,7 +113,7 @@ export function createLocalProvider(basePath) {
112
113
  entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
113
114
  }
114
115
  catch (err) {
115
- const code = err.code;
116
+ const code = errorCodeOf(err);
116
117
  if (code === "ENOENT" || code === "ENOTDIR")
117
118
  return [];
118
119
  throw err;
@@ -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 {};
@@ -1,34 +1,60 @@
1
1
  import { deleteMany, selectMany } from "../db/query.js";
2
2
  import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
3
3
  export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
+ const PAGE_ORDER = [
5
+ { col: "deletedAt", direction: "asc" },
6
+ { col: "id", direction: "asc" },
7
+ ];
8
+ // Keyset page over (deletedAt, id): rows at the cursor's instant with a larger id first, then
9
+ // strictly later instants. `where` already bounds deletedAt by the cutoff; the later-instants
10
+ // query repeats that bound next to the cursor.
11
+ async function selectNextPage(args) {
12
+ const { db, table, where, expiredBefore, cursor, pageSize } = args;
13
+ const options = { orderBy: PAGE_ORDER, limit: pageSize };
14
+ if (cursor === undefined)
15
+ return selectMany(db, table, where, options);
16
+ const sameInstant = await selectMany(db, table, { ...where, deletedAt: cursor.deletedAt, id: { gt: cursor.id } }, options);
17
+ if (sameInstant.length >= pageSize)
18
+ return sameInstant;
19
+ const later = await selectMany(db, table, { ...where, deletedAt: { gt: cursor.deletedAt, lt: expiredBefore } }, { ...options, limit: pageSize - sameInstant.length });
20
+ return [...sameInstant, ...later];
21
+ }
4
22
  // Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
5
23
  // rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
6
- // row whose bytes could not be removed stays and the next run retries it.
24
+ // row whose bytes could not be removed stays and the next run retries it. A run walks the
25
+ // expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
26
+ // rows behind them; the run ends on the first empty page.
7
27
  export async function purgeExpiredFileRefsWithBytes(args) {
8
- const { db, table, where, files, log } = args;
28
+ const { db, table, where, expiredBefore, files, log } = args;
29
+ const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
9
30
  if (!files) {
10
31
  log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
11
32
  return;
12
33
  }
13
- const rows = await selectMany(db, table, where, {
14
- orderBy: [{ col: "deletedAt", direction: "asc" }],
15
- limit: EXPIRED_FILE_REF_PURGE_BATCH,
16
- });
17
- const purgedIds = [];
18
34
  const store = { list: files.list, delete: (key) => files.ref(key).delete() };
19
- for (const row of rows) {
20
- try {
21
- const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
22
- if (failedKeys.length === 0) {
23
- purgedIds.push(row.id);
24
- continue;
35
+ let cursor;
36
+ for (;;) {
37
+ const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
38
+ const lastRow = rows[rows.length - 1];
39
+ // skip: an empty page means every expired row has been visited
40
+ if (lastRow === undefined)
41
+ return;
42
+ cursor = lastRow;
43
+ const purgedIds = [];
44
+ for (const row of rows) {
45
+ try {
46
+ const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
47
+ if (failedKeys.length === 0) {
48
+ purgedIds.push(row.id);
49
+ continue;
50
+ }
51
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
52
+ }
53
+ catch (err) {
54
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
25
55
  }
26
- log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
27
- }
28
- catch (err) {
29
- log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
30
56
  }
57
+ if (purgedIds.length > 0)
58
+ await deleteMany(db, table, { ...where, id: { in: purgedIds } });
31
59
  }
32
- if (purgedIds.length > 0)
33
- await deleteMany(db, table, { ...where, id: { in: purgedIds } });
34
60
  }
@@ -1,5 +1,5 @@
1
1
  import type { Registry } from "../engine/types/index.js";
2
- export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
2
+ export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
3
3
  export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
4
4
  export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
5
5
  export type I18nOptions = {
@@ -1,5 +1,5 @@
1
1
  import { resolveTranslationValue } from "../ui-types/plural.js";
2
- export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
2
+ export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
3
3
  export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
4
4
  export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
5
5
  export function createI18n(registry, options) {
@@ -1,5 +1,7 @@
1
1
  import { type TranslationValue } from "../ui-types/plural.js";
2
2
  export declare function registerMailTranslations(locale: string, bundle: Readonly<Record<string, TranslationValue>>): void;
3
+ export declare function listMailLocales(): readonly string[];
4
+ export declare function hasMailTranslationKey(locale: string, key: string): boolean;
3
5
  export declare function hasMailTranslations(locale: string): boolean;
4
6
  export declare function mailT(locale: string, key: string, params?: Readonly<Record<string, string | number>>): string;
5
7
  /** Locale key mailT would actually use (exact → root → en). Use for appUrl
@@ -4,6 +4,13 @@ export function registerMailTranslations(locale, bundle) {
4
4
  const prev = tables.get(locale) ?? {};
5
5
  tables.set(locale, { ...prev, ...bundle });
6
6
  }
7
+ export function listMailLocales() {
8
+ return [...tables.keys()];
9
+ }
10
+ // Own key only, without the root/en fallback mailT applies.
11
+ export function hasMailTranslationKey(locale, key) {
12
+ return tables.get(locale)?.[key] !== undefined;
13
+ }
7
14
  export function hasMailTranslations(locale) {
8
15
  const root = locale.split("-")[0] ?? locale;
9
16
  return tables.has(locale) || tables.has(root);
@@ -1,2 +1,2 @@
1
- export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
1
+ export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
2
2
  export { createJobRunner, serializeJobSubject } from "./job-runner.js";
@@ -29,6 +29,9 @@ export type JobQueuedMeta = {
29
29
  readonly tenantId: string;
30
30
  readonly subject: string | null;
31
31
  };
32
+ export type JobDroppedMeta = {
33
+ readonly tenantId: string;
34
+ };
32
35
  export type JobOutcomeMeta = {
33
36
  readonly tenantId: string;
34
37
  readonly finalAttempt: boolean;
@@ -66,6 +69,6 @@ export type JobRunnerOptions = {
66
69
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
67
70
  onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
68
71
  onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
69
- onJobDropped?: (jobName: string, jobId: string) => void;
72
+ onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
70
73
  };
71
74
  export declare function createJobRunner(options: JobRunnerOptions): JobRunner;