@cosmicdrift/kumiko-bundled-features 0.333.0 → 0.335.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 (277) hide show
  1. package/dist/admin-shell/overview-panels.js +2 -2
  2. package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
  3. package/dist/agent-tools/agent-doc-lint.js +33 -3
  4. package/dist/agent-tools/agent-manifest.js +47 -6
  5. package/dist/agent-tools/filter-ops.d.ts +2 -0
  6. package/dist/agent-tools/filter-ops.js +4 -0
  7. package/dist/agent-tools/tool-catalog.js +1 -4
  8. package/dist/agent-tools/tool-dispatch.js +1 -4
  9. package/dist/audit/constants.d.ts +1 -1
  10. package/dist/audit/constants.js +1 -1
  11. package/dist/audit/handlers/list.query.js +4 -3
  12. package/dist/auth-email-password/handlers/change-password.write.js +2 -0
  13. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  14. package/dist/auth-email-password/handlers/login.write.js +2 -0
  15. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  16. package/dist/auth-email-password/i18n.js +2 -0
  17. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  18. package/dist/auth-email-password/web/auth-client.js +1 -1
  19. package/dist/auth-email-password/web/auth-gate.js +6 -8
  20. package/dist/auth-email-password/web/index.d.ts +1 -1
  21. package/dist/auth-email-password/web/index.js +1 -1
  22. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  23. package/dist/auth-email-password/web/login-screen.js +1 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  25. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  26. package/dist/auth-email-password/web/session.d.ts +4 -0
  27. package/dist/auth-email-password/web/session.js +15 -0
  28. package/dist/auth-mfa/feature.js +3 -2
  29. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  30. package/dist/auth-mfa/handlers/verify.write.js +2 -0
  31. package/dist/auth-mfa/hmac-token-codec.js +3 -0
  32. package/dist/auth-mfa/mfa-challenge-token.js +4 -0
  33. package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
  34. package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
  35. package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
  36. package/dist/auth-mfa/web/mfa-client.js +4 -10
  37. package/dist/billing-foundation/billing-info-query.js +1 -1
  38. package/dist/billing-foundation/checkout-core.d.ts +5 -5
  39. package/dist/billing-foundation/checkout-core.js +10 -6
  40. package/dist/billing-foundation/entities.d.ts +8 -0
  41. package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
  42. package/dist/billing-foundation/projection.d.ts +8 -0
  43. package/dist/cap-counter/book-cap-usage.js +15 -5
  44. package/dist/cap-counter/enforce-cap.js +9 -3
  45. package/dist/cap-counter/entity.d.ts +1 -0
  46. package/dist/cap-counter/feature.js +3 -2
  47. package/dist/cap-counter/i18n.d.ts +1 -0
  48. package/dist/cap-counter/i18n.js +6 -5
  49. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  50. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  51. package/dist/cap-overview/i18n.js +5 -0
  52. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  53. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  54. package/dist/channel-email/email-channel.js +12 -1
  55. package/dist/channel-in-app/tables.js +2 -6
  56. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  57. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -2
  58. package/dist/config/db/queries/resolver.js +3 -1
  59. package/dist/config/handlers/set.write.js +8 -4
  60. package/dist/config/table.d.ts +6 -0
  61. package/dist/config/web/i18n.js +13 -4
  62. package/dist/config/write-helpers.d.ts +1 -1
  63. package/dist/config/write-helpers.js +11 -5
  64. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  65. package/dist/custom-fields/constants.js +1 -4
  66. package/dist/custom-fields/entity.d.ts +4 -0
  67. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  68. package/dist/data-retention/run-retention-cleanup.js +100 -44
  69. package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
  70. package/dist/delivery/tables.d.ts +6 -0
  71. package/dist/delivery/types.d.ts +2 -1
  72. package/dist/delivery/types.js +7 -0
  73. package/dist/delivery/unsubscribe.js +5 -11
  74. package/dist/derivatives-sharp/render.js +27 -9
  75. package/dist/document-ingest-foundation/entity.d.ts +4 -0
  76. package/dist/document-ingest-foundation/feature.js +5 -5
  77. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  78. package/dist/document-ingest-foundation/providers.js +6 -0
  79. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  80. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  81. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  82. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  83. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  84. package/dist/file-provider-s3/feature.js +1 -1
  85. package/dist/files-tenant-data/index.js +3 -2
  86. package/dist/folders/entity.d.ts +8 -0
  87. package/dist/folders/handlers/clear-folder.write.js +4 -11
  88. package/dist/folders/handlers/set-folder.write.js +4 -10
  89. package/dist/form-draft/entity.d.ts +2 -0
  90. package/dist/form-draft/feature.js +3 -2
  91. package/dist/form-draft/handlers/discard.write.js +7 -8
  92. package/dist/form-draft/handlers/get.query.js +7 -1
  93. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  94. package/dist/foundation-shared/host-allowlist.js +14 -0
  95. package/dist/foundation-shared/index.d.ts +1 -0
  96. package/dist/foundation-shared/index.js +1 -0
  97. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  98. package/dist/foundation-shared/mail-host-policy.js +3 -13
  99. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  100. package/dist/inbound-mail-foundation/entities.d.ts +25 -0
  101. package/dist/inbound-mail-foundation/projection.d.ts +20 -0
  102. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  103. package/dist/jobs/handlers/list.query.d.ts +7 -2
  104. package/dist/jobs/handlers/list.query.js +9 -2
  105. package/dist/jobs/handlers/retry.write.js +3 -2
  106. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  107. package/dist/jobs/job-run-logger.js +18 -2
  108. package/dist/jobs/job-run-table.d.ts +6 -0
  109. package/dist/jobs/tenant-job-failure-table.js +2 -6
  110. package/dist/ledger/entity.d.ts +16 -3
  111. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  112. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  113. package/dist/ledger/subject-reference.d.ts +4 -0
  114. package/dist/ledger/subject-reference.js +7 -0
  115. package/dist/legal-pages/feature.js +8 -1
  116. package/dist/managed-pages/handlers/branding.query.js +1 -1
  117. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  118. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  119. package/dist/managed-pages/table.d.ts +12 -0
  120. package/dist/metrics/compute.js +7 -1
  121. package/dist/metrics/window.js +5 -0
  122. package/dist/notes-history/entity.d.ts +12 -0
  123. package/dist/notes-history/handlers/add-note.write.js +4 -9
  124. package/dist/notes-history-user-data/hooks.js +8 -1
  125. package/dist/personal-access-tokens/constants.d.ts +1 -0
  126. package/dist/personal-access-tokens/constants.js +1 -0
  127. package/dist/personal-access-tokens/feature.js +2 -2
  128. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  129. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  130. package/dist/personal-access-tokens/i18n.js +12 -13
  131. package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
  132. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  133. package/dist/personal-access-tokens/screens.js +5 -1
  134. package/dist/rate-limiting/feature.js +2 -0
  135. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  136. package/dist/rate-limiting/i18n.d.ts +10 -0
  137. package/dist/rate-limiting/i18n.js +10 -0
  138. package/dist/secrets/feature.js +3 -2
  139. package/dist/secrets/table.d.ts +1 -0
  140. package/dist/seo/handlers/seo-config.query.js +1 -1
  141. package/dist/sessions/constants.d.ts +1 -0
  142. package/dist/sessions/constants.js +1 -0
  143. package/dist/sessions/feature.js +5 -3
  144. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  145. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  146. package/dist/sessions/handlers/revoke.write.js +2 -2
  147. package/dist/sessions/schema/user-session.d.ts +4 -0
  148. package/dist/sessions/schema/user-session.js +2 -1
  149. package/dist/sessions/screens.js +5 -1
  150. package/dist/sessions/session-callbacks.js +2 -2
  151. package/dist/shared/index.d.ts +1 -1
  152. package/dist/shared/index.js +1 -1
  153. package/dist/shared/lockout-counter.js +1 -1
  154. package/dist/shared/parent-visibility.d.ts +5 -0
  155. package/dist/shared/parent-visibility.js +17 -1
  156. package/dist/shared/row-bound-grant.js +20 -9
  157. package/dist/shared/single-use-token-store.js +5 -13
  158. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  159. package/dist/step-dispatcher/webhook-runner.js +18 -17
  160. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  161. package/dist/subscription-mollie/plugin-methods.js +7 -1
  162. package/dist/subscription-mollie/verify-webhook.js +6 -0
  163. package/dist/subscription-stripe/feature.js +3 -3
  164. package/dist/subscription-stripe/plugin-methods.js +25 -6
  165. package/dist/subscription-stripe/verify-webhook.js +13 -6
  166. package/dist/tags/entity.d.ts +9 -0
  167. package/dist/tags/handlers/assign-tag.write.js +4 -10
  168. package/dist/tags/handlers/remove-tag.write.js +4 -11
  169. package/dist/template-resolver/api.d.ts +2 -1
  170. package/dist/template-resolver/api.js +1 -1
  171. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  172. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  173. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  174. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  175. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  176. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  177. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  178. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  179. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  180. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  181. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  182. package/dist/template-resolver/handlers/list.query.js +1 -1
  183. package/dist/template-resolver/seeding.js +41 -39
  184. package/dist/template-resolver/table.d.ts +26 -13
  185. package/dist/template-resolver/testing.js +2 -2
  186. package/dist/template-resolver/user-content-table.d.ts +16 -9
  187. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  188. package/dist/template-resolver-user-data/hooks.js +1 -1
  189. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  190. package/dist/tenant/cancel-pending-invitation.js +22 -0
  191. package/dist/tenant/feature.js +4 -2
  192. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  193. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  194. package/dist/tenant/handlers/member-directory.query.js +17 -4
  195. package/dist/tenant/handlers/remove-member.write.js +9 -14
  196. package/dist/tenant/handlers/team-list.query.js +2 -2
  197. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  198. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  199. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  200. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  201. package/dist/tenant/i18n.js +4 -0
  202. package/dist/tenant/index.d.ts +1 -1
  203. package/dist/tenant/index.js +1 -1
  204. package/dist/tenant/invitation-table.d.ts +8 -2
  205. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  206. package/dist/tenant/last-tenant-admin.js +5 -3
  207. package/dist/tenant/membership-table.d.ts +4 -0
  208. package/dist/tenant/schema/tenant.d.ts +8 -2
  209. package/dist/tenant/seeding.d.ts +4 -0
  210. package/dist/tenant/seeding.js +9 -2
  211. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  212. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  213. package/dist/tenant-lifecycle/feature.js +2 -1
  214. package/dist/tenant-lifecycle/stages.js +2 -2
  215. package/dist/tenant-settings/feature.js +9 -6
  216. package/dist/tier-engine/entity.d.ts +2 -0
  217. package/dist/tier-engine/feature.js +3 -2
  218. package/dist/tier-engine/tier-resolver.d.ts +2 -0
  219. package/dist/user/command-schemas.d.ts +2 -2
  220. package/dist/user/handlers/create.write.d.ts +1 -1
  221. package/dist/user/handlers/create.write.js +8 -2
  222. package/dist/user/handlers/update.write.d.ts +1 -1
  223. package/dist/user/handlers/update.write.js +8 -2
  224. package/dist/user/schema/user.d.ts +14 -2
  225. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  226. package/dist/user-data-rights/feature.js +22 -9
  227. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  228. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  229. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  230. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  231. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  232. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  233. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  234. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  235. package/dist/user-data-rights/run-export-jobs.js +74 -41
  236. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  237. package/dist/user-data-rights/run-user-export.js +8 -4
  238. package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
  239. package/dist/user-data-rights/schema/download-token.d.ts +8 -0
  240. package/dist/user-data-rights/schema/export-job.d.ts +8 -2
  241. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
  242. package/dist/user-profile/feature.js +6 -1
  243. package/dist/user-profile/handlers/change-email.write.js +2 -0
  244. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  245. package/dist/workflow-runner/event-subscriber.js +29 -9
  246. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  247. package/dist/workflow-runner/pending-projection.js +8 -1
  248. package/package.json +11 -9
  249. package/src/agent-tools/changes.json +12 -0
  250. package/src/auth-email-password/changes.json +18 -0
  251. package/src/auth-mfa/changes.json +6 -0
  252. package/src/billing-foundation/changes.json +7 -0
  253. package/src/cap-counter/changes.json +7 -0
  254. package/src/cap-overview/changes.json +12 -0
  255. package/src/channel-email/changes.json +6 -0
  256. package/src/config/changes.json +6 -0
  257. package/src/crypto-shredding/changes.json +6 -0
  258. package/src/data-retention/changes.json +12 -0
  259. package/src/delivery/changes.json +7 -0
  260. package/src/derivatives-sharp/changes.json +11 -0
  261. package/src/document-ingest-foundation/changes.json +12 -0
  262. package/src/inbound-mail-foundation/changes.json +12 -0
  263. package/src/jobs/changes.json +13 -0
  264. package/src/legal-pages/changes.json +8 -1
  265. package/src/managed-pages/changes.json +5 -0
  266. package/src/metrics/changes.json +6 -0
  267. package/src/rate-limiting/changes.json +5 -0
  268. package/src/secrets/changes.json +7 -0
  269. package/src/sessions/changes.json +5 -0
  270. package/src/shared/changes.json +6 -0
  271. package/src/step-dispatcher/changes.json +5 -0
  272. package/src/subscription-mollie/changes.json +7 -0
  273. package/src/subscription-stripe/changes.json +11 -0
  274. package/src/template-resolver/changes.json +7 -0
  275. package/src/tenant/changes.json +29 -0
  276. package/src/user-data-rights/changes.json +22 -0
  277. package/src/workflow-runner/changes.json +6 -0
@@ -41,7 +41,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
41
41
  import { collectPiiSubjectFields, configuredPiiSubjectKms, isSelfPiiField, resolveSubjectForField, subjectIdToKey, } from "@cosmicdrift/kumiko-framework/crypto";
42
42
  import { createEventStoreExecutor, createTenantDb, nullBlindIndexesForSubject, } from "@cosmicdrift/kumiko-framework/db";
43
43
  import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
44
- import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
44
+ import { createSystemUser, entityHasAnonymizableSubjectField, } from "@cosmicdrift/kumiko-framework/engine";
45
45
  import { fileRefEntity, fileRefsTable, } from "@cosmicdrift/kumiko-framework/files";
46
46
  import { runInSubTransaction } from "../shared/index.js";
47
47
  import { computeCutoff } from "./keep-for.js";
@@ -204,22 +204,62 @@ function isFileRefBoundElsewhere(fileRef, entityName, rowId) {
204
204
  return true;
205
205
  return false;
206
206
  }
207
- // A single-field fileRef-id can be shared by more than one row of the SAME
208
- // table (e.g. a stock-photo re-picked across records). Purging its bytes
209
- // would corrupt the row that isn't being deleted. Bounded by batchLimit rows
210
- // per run and singleFields.length queries per fileRef — the same per-row
211
- // event-cost tradeoff as the rest of this cron (see header).
212
- async function isFileRefSharedByOtherRow(db, table, tableHasTenantId, tenantId, rowId, singleFields, fileRefId) {
213
- for (const field of singleFields) {
214
- const where = { [field]: fileRefId, id: { ne: rowId } };
215
- if (tableHasTenantId)
216
- where["tenantId"] = tenantId;
217
- const others = await selectMany(db, table, where, { limit: 1 });
218
- if (others.length > 0)
219
- return true;
207
+ // Every implicit entity table with file/image fields: an unbound fileRef
208
+ // (POST /files without entityType/entityId) can sit in single fields of
209
+ // several entity types, so the sharing check must look beyond the purged
210
+ // row's own table.
211
+ function collectSingleFileHolders(registry) {
212
+ const holders = [];
213
+ for (const proj of registry.getAllProjections().values()) {
214
+ if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
215
+ continue;
216
+ const entity = registry.getEntity(proj.source);
217
+ if (!entity)
218
+ continue;
219
+ const { singleFields } = planFileFields(entity.fields);
220
+ if (singleFields.length === 0)
221
+ continue;
222
+ const table = proj.table; // @cast-boundary column-presence probe
223
+ holders.push({
224
+ table: proj.table,
225
+ tableHasTenantId: table["tenantId"] !== undefined,
226
+ singleFields,
227
+ });
228
+ }
229
+ return holders;
230
+ }
231
+ // A single-field fileRef-id can be shared by more than one row, in the SAME
232
+ // table (e.g. a stock-photo re-picked across records) or in another entity's
233
+ // table. Purging its bytes would corrupt the row that isn't being deleted.
234
+ // Bounded by batchLimit rows per run and holderFields queries per fileRef —
235
+ // the same per-row event-cost tradeoff as the rest of this cron (see header).
236
+ async function isFileRefSharedByOtherRow(db, holders, ownTable, tenantId, rowId, fileRefId) {
237
+ for (const holder of holders) {
238
+ for (const field of holder.singleFields) {
239
+ const where = { [field]: fileRefId };
240
+ if (holder.table === ownTable)
241
+ where["id"] = { ne: rowId };
242
+ if (holder.tableHasTenantId)
243
+ where["tenantId"] = tenantId;
244
+ const others = await selectMany(db, holder.table, where, { limit: 1 });
245
+ if (others.length > 0)
246
+ return true;
247
+ }
220
248
  }
221
249
  return false;
222
250
  }
251
+ // Throws (unsafe key, missing list permission) must fail this row closed,
252
+ // not abort the whole tenant run.
253
+ async function deleteFileFailingClosed(key, store, logContext) {
254
+ try {
255
+ return await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
256
+ }
257
+ catch (err) {
258
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
259
+ console.warn(`[data-retention:hardDelete] ${logContext} storage cleanup threw: ${err instanceof Error ? err.message : String(err)}`);
260
+ return [key];
261
+ }
262
+ }
223
263
  // hardDelete for ONE row of an entity that has file/image/files/images
224
264
  // fields: delete the un-shared fileRefs' bytes (+ derivatives) first, then
225
265
  // their fileRef rows, then the entity row itself — bytes before rows so a
@@ -251,7 +291,7 @@ async function purgeHardDeleteRowWithFiles(args) {
251
291
  for (const fileRef of fileRefRows) {
252
292
  if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId))
253
293
  continue;
254
- const shared = await isFileRefSharedByOtherRow(args.db, args.table, args.tableHasTenantId, args.tenantId, rowId, args.plan.singleFields, String(fileRef["id"]));
294
+ const shared = await isFileRefSharedByOtherRow(args.db, args.fileHolders, args.table, args.tenantId, rowId, String(fileRef["id"]));
255
295
  if (shared)
256
296
  continue;
257
297
  toDelete.push(fileRef);
@@ -270,7 +310,7 @@ async function purgeHardDeleteRowWithFiles(args) {
270
310
  const key = fileRef["storageKey"];
271
311
  if (typeof key !== "string" || key.length === 0)
272
312
  continue;
273
- const failedKeys = await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
313
+ const failedKeys = await deleteFileFailingClosed(key, store, `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`);
274
314
  if (failedKeys.length > 0) {
275
315
  anyFailed = true;
276
316
  // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
@@ -295,9 +335,6 @@ async function purgeHardDeleteRowWithFiles(args) {
295
335
  // each skip reason at most once per entity per run (repeat skips would just
296
336
  // be noise; the per-row failure is already warned above).
297
337
  async function purgeHardDeleteRowsWithFiles(args) {
298
- const rows = await selectMany(args.db, args.table, args.where, {
299
- limit: args.batchLimit,
300
- });
301
338
  const reasonsSeen = new Set();
302
339
  const onSkip = (reason) => {
303
340
  // skip: reason already reported for this entity in this run
@@ -306,28 +343,44 @@ async function purgeHardDeleteRowsWithFiles(args) {
306
343
  reasonsSeen.add(reason);
307
344
  args.skipped.push({ entityName: args.entityName, reason });
308
345
  };
346
+ // id-cursor paging: rows skipped (missing storage, failed delete) stay in the
347
+ // table, so an unordered limit-page would re-fetch the same stuck rows every
348
+ // run and starve every row behind them. batchLimit bounds the DELETES.
309
349
  let count = 0;
310
- for (const row of rows) {
311
- const ok = await purgeHardDeleteRowWithFiles({
312
- db: args.db,
313
- table: args.table,
314
- tableHasTenantId: args.tableHasTenantId,
315
- tenantId: args.tenantId,
316
- entityName: args.entityName,
317
- entity: args.entity,
318
- row,
319
- plan: args.plan,
320
- files: args.files,
321
- entityExecutor: args.entityExecutor,
322
- fileRefExecutor: args.fileRefExecutor,
323
- systemUser: args.systemUser,
324
- tdb: args.tdb,
325
- kms: args.kms,
326
- registry: args.registry,
327
- onSkip,
350
+ let cursor = null;
351
+ while (count < args.batchLimit) {
352
+ const pageWhere = cursor === null ? args.where : { ...args.where, id: { gt: cursor } };
353
+ const rows = await selectMany(args.db, args.table, pageWhere, {
354
+ limit: args.batchLimit,
355
+ orderBy: { col: "id" },
328
356
  });
329
- if (ok)
330
- count++;
357
+ if (rows.length === 0)
358
+ break;
359
+ for (const row of rows) {
360
+ cursor = row["id"]; // @cast-boundary db-row
361
+ const ok = await purgeHardDeleteRowWithFiles({
362
+ db: args.db,
363
+ table: args.table,
364
+ fileHolders: args.fileHolders,
365
+ tenantId: args.tenantId,
366
+ entityName: args.entityName,
367
+ entity: args.entity,
368
+ row,
369
+ plan: args.plan,
370
+ files: args.files,
371
+ entityExecutor: args.entityExecutor,
372
+ fileRefExecutor: args.fileRefExecutor,
373
+ systemUser: args.systemUser,
374
+ tdb: args.tdb,
375
+ kms: args.kms,
376
+ registry: args.registry,
377
+ onSkip,
378
+ });
379
+ if (ok)
380
+ count++;
381
+ if (count >= args.batchLimit)
382
+ break;
383
+ }
331
384
  }
332
385
  return count;
333
386
  }
@@ -394,6 +447,7 @@ export async function runRetentionCleanup(args) {
394
447
  // filter shrinks the set). A single batched forget/delete event is a follow-up.
395
448
  const systemUser = createSystemUser(tenantId);
396
449
  const tdb = createTenantDb(db, tenantId, "system");
450
+ const fileHolders = collectSingleFileHolders(registry);
397
451
  // Pre-load every override row for this tenant ONCE — N entities × M
398
452
  // tenants would otherwise mean one fetchOne per entity per tenant, even
399
453
  // though the whole set for a tenant is a single small, indexed read.
@@ -458,7 +512,7 @@ export async function runRetentionCleanup(args) {
458
512
  hardDeleted += await purgeHardDeleteRowsWithFiles({
459
513
  db,
460
514
  table: proj.table,
461
- tableHasTenantId: table["tenantId"] !== undefined,
515
+ fileHolders,
462
516
  where,
463
517
  batchLimit,
464
518
  tenantId,
@@ -495,10 +549,12 @@ export async function runRetentionCleanup(args) {
495
549
  case "blockDelete": {
496
550
  const targets = await resolveAnonymizeTargets(entity.fields);
497
551
  if (!targets) {
498
- // blockDelete without anonymize fields is a pure hold: a subjectRef-only
499
- // person link outlives the hold and is cut only by the user-data
500
- // forget hook, so expiry has nothing to do here and is not reported.
501
- if (policy.strategy === "anonymize") {
552
+ // blockDelete without anonymize fields is a pure hold only while the
553
+ // entity has no field that CAN carry anonymize: a subjectRef-only
554
+ // person link outlives the hold and is cut by the user-data forget
555
+ // hook. A personal field lacking its anonymize function would stay
556
+ // stored past the hold, so that case is still reported.
557
+ if (policy.strategy === "anonymize" || entityHasAnonymizableSubjectField(entity.fields)) {
502
558
  skipped.push({ entityName, reason: "missing_anonymize_fields" });
503
559
  }
504
560
  break;
@@ -9,6 +9,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
9
9
  readonly filterable?: boolean;
10
10
  readonly encrypted?: boolean;
11
11
  readonly sensitive?: boolean;
12
+ readonly writeOnly?: boolean;
12
13
  readonly readAsInstruction?: boolean;
13
14
  readonly format?: "email" | "url" | "phone" | "password";
14
15
  readonly default?: string;
@@ -45,6 +46,7 @@ export declare const tenantRetentionOverrideEntity: import("@cosmicdrift/kumiko-
45
46
  readonly filterable?: boolean;
46
47
  readonly encrypted?: boolean;
47
48
  readonly sensitive?: boolean;
49
+ readonly writeOnly?: boolean;
48
50
  readonly readAsInstruction?: boolean;
49
51
  readonly format?: "email" | "url" | "phone" | "password";
50
52
  readonly default?: string;
@@ -67,6 +69,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
67
69
  readonly filterable?: boolean;
68
70
  readonly encrypted?: boolean;
69
71
  readonly sensitive?: boolean;
72
+ readonly writeOnly?: boolean;
70
73
  readonly readAsInstruction?: boolean;
71
74
  readonly format?: "email" | "url" | "phone" | "password";
72
75
  readonly default?: string;
@@ -103,6 +106,7 @@ export declare const tenantRetentionOverrideTable: import("@cosmicdrift/kumiko-f
103
106
  readonly filterable?: boolean;
104
107
  readonly encrypted?: boolean;
105
108
  readonly sensitive?: boolean;
109
+ readonly writeOnly?: boolean;
106
110
  readonly readAsInstruction?: boolean;
107
111
  readonly format?: "email" | "url" | "phone" | "password";
108
112
  readonly default?: string;
@@ -12,6 +12,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
12
12
  readonly filterable?: boolean;
13
13
  readonly encrypted?: boolean;
14
14
  readonly sensitive?: boolean;
15
+ readonly writeOnly?: boolean;
15
16
  readonly readAsInstruction?: boolean;
16
17
  readonly format?: "email" | "url" | "phone" | "password";
17
18
  readonly default?: string;
@@ -32,6 +33,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
32
33
  readonly filterable?: boolean;
33
34
  readonly encrypted?: boolean;
34
35
  readonly sensitive?: boolean;
36
+ readonly writeOnly?: boolean;
35
37
  readonly readAsInstruction?: boolean;
36
38
  readonly format?: "email" | "url" | "phone" | "password";
37
39
  readonly default?: string;
@@ -52,6 +54,7 @@ export declare const notificationPreferenceEntity: import("@cosmicdrift/kumiko-t
52
54
  readonly filterable?: boolean;
53
55
  readonly encrypted?: boolean;
54
56
  readonly sensitive?: boolean;
57
+ readonly writeOnly?: boolean;
55
58
  readonly readAsInstruction?: boolean;
56
59
  readonly format?: "email" | "url" | "phone" | "password";
57
60
  readonly default?: string;
@@ -78,6 +81,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
78
81
  readonly filterable?: boolean;
79
82
  readonly encrypted?: boolean;
80
83
  readonly sensitive?: boolean;
84
+ readonly writeOnly?: boolean;
81
85
  readonly readAsInstruction?: boolean;
82
86
  readonly format?: "email" | "url" | "phone" | "password";
83
87
  readonly default?: string;
@@ -98,6 +102,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
98
102
  readonly filterable?: boolean;
99
103
  readonly encrypted?: boolean;
100
104
  readonly sensitive?: boolean;
105
+ readonly writeOnly?: boolean;
101
106
  readonly readAsInstruction?: boolean;
102
107
  readonly format?: "email" | "url" | "phone" | "password";
103
108
  readonly default?: string;
@@ -118,6 +123,7 @@ export declare const notificationAddressOptOutEntity: import("@cosmicdrift/kumik
118
123
  readonly filterable?: boolean;
119
124
  readonly encrypted?: boolean;
120
125
  readonly sensitive?: boolean;
126
+ readonly writeOnly?: boolean;
121
127
  readonly readAsInstruction?: boolean;
122
128
  readonly format?: "email" | "url" | "phone" | "password";
123
129
  readonly default?: string;
@@ -23,7 +23,8 @@ export type RenderedMessage = {
23
23
  readonly html: string;
24
24
  readonly subject: string;
25
25
  };
26
- export type DeliveryChannelMode = "inline" | "queued";
26
+ export declare const DELIVERY_CHANNEL_MODES: readonly ["inline", "queued"];
27
+ export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
27
28
  export type DeliveryChannel = {
28
29
  readonly name: string;
29
30
  readonly mode: DeliveryChannelMode;
@@ -1,8 +1,15 @@
1
1
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
2
+ // `mode` decides how the delivery-service dispatches a channel:
3
+ // inline — sent synchronously inside notify() (inApp: DB insert + SSE).
4
+ // queued — sent asynchronously via the delivery.send job; channels with a
5
+ // render() additionally run through delivery.render first.
6
+ export const DELIVERY_CHANNEL_MODES = ["inline", "queued"];
2
7
  export function isDeliveryChannelPlugin(o) {
3
8
  return (typeof o === "object" &&
4
9
  o !== null &&
5
10
  "mode" in o &&
11
+ DELIVERY_CHANNEL_MODES.some((mode) => mode === o.mode) &&
12
+ (!("render" in o) || o.render === undefined || typeof o.render === "function") &&
6
13
  "resolve" in o &&
7
14
  typeof o.resolve === "function" &&
8
15
  "send" in o &&
@@ -213,13 +213,7 @@ function confirmationPage(token) {
213
213
  // One-click clients put the token in the query string; only the
214
214
  // confirmation-page form submits it in the body.
215
215
  function tokenFromPostRequest(request) {
216
- const contentType = request.headers["content-type"];
217
- if (contentType?.includes("application/x-www-form-urlencoded")) {
218
- const fromBody = new URLSearchParams(request.rawBody).get("token");
219
- if (fromBody)
220
- return fromBody;
221
- }
222
- return request.query["token"];
216
+ return tokenFromRequestBody(request) ?? request.query["token"];
223
217
  }
224
218
  // Resubscribe is deliberately NOT reachable via the query string — a mail
225
219
  // client's link prefetcher (or a scanner) following an unsubscribe link
@@ -227,10 +221,10 @@ function tokenFromPostRequest(request) {
227
221
  // convention is built to avoid; resubscribe carries the same risk in
228
222
  // reverse (a prefetch that undoes a real opt-out) and gets no query-param
229
223
  // fallback at all.
230
- function tokenFromResubscribeRequest(request) {
224
+ function tokenFromRequestBody(request) {
231
225
  const contentType = request.headers["content-type"];
232
226
  if (contentType?.includes("application/x-www-form-urlencoded")) {
233
- return new URLSearchParams(request.rawBody).get("token") ?? undefined;
227
+ return new URLSearchParams(request.rawBody).get("token") || undefined;
234
228
  }
235
229
  if (contentType?.includes("application/json")) {
236
230
  try {
@@ -278,13 +272,13 @@ export function createUnsubscribeRoutes(options) {
278
272
  },
279
273
  });
280
274
  // Undo direction: token only from the JSON/form body, never the query
281
- // (see tokenFromResubscribeRequest) — no GET confirmation-page variant,
275
+ // (see tokenFromRequestBody) — no GET confirmation-page variant,
282
276
  // this is a one-shot POST from the unsubscribe-page's "Undo" button.
283
277
  const resubscribeRoute = signatureRoute({
284
278
  method: "POST",
285
279
  path: DELIVERY_RESUBSCRIBE_PATH,
286
280
  entry: "signature",
287
- verify: async (request) => verifyUnsubscribeToken(tokenFromResubscribeRequest(request), encodedSecret),
281
+ verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret),
288
282
  handler: async (c, verified, deps) => {
289
283
  const outcome = await dispatchResubscribeWrite(verified, deps);
290
284
  if (outcome === "limit_reached") {
@@ -124,24 +124,40 @@ function overlayPosition(gravity, marginPx, outputWidth, outputHeight, layerWidt
124
124
  // fraction of the output is a fraction of what the caller actually gets,
125
125
  // regardless of the source's own dimensions or `fit` crop.
126
126
  async function applyOverlays(pipeline, layers) {
127
- const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
127
+ // PNG intermediate: a bare toBuffer() re-encodes in the source format, so a
128
+ // JPEG source would gain an extra lossy generation before applyEncoder runs.
129
+ const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
128
130
  const composites = [];
129
131
  for (const layer of layers) {
132
+ const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
133
+ // The QR is square, so the output height bounds its side just like the width does.
134
+ const qrSide = Math.min(targetWidth, info.height);
135
+ if (layer.kind === "qr" && qrSide < MIN_QR_PIXEL_WIDTH) {
136
+ throw new Error(`derivatives-sharp: qr overlay would render at ${qrSide}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
137
+ }
138
+ // Rendered at its final size so the modules stay hard-edged; resampling a
139
+ // small default-size QR to a non-integer module size blurs them.
130
140
  const layerBytes = layer.kind === "qr"
131
- ? await QRCode.toBuffer(layer.data, { margin: 1, errorCorrectionLevel: "M" })
141
+ ? await QRCode.toBuffer(layer.data, {
142
+ width: qrSide,
143
+ margin: 1,
144
+ errorCorrectionLevel: "M",
145
+ })
132
146
  : Buffer.from(layer.imageBase64, "base64");
133
147
  const layerMeta = await sharp(layerBytes, SHARP_INPUT_OPTIONS).metadata();
134
148
  assertNotSvg(layerMeta.format);
135
- const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
136
- if (layer.kind === "qr" && targetWidth < MIN_QR_PIXEL_WIDTH) {
137
- throw new Error(`derivatives-sharp: qr overlay would render at ${targetWidth}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`);
138
- }
139
149
  // PNG (not the layer's own format) to keep any alpha through the resize.
140
150
  // height caps the other axis too — sharp's composite() rejects an input
141
151
  // larger than the base image, which a tall/square layer would otherwise
142
152
  // hit once widthPct alone drives its width past the output's height.
143
153
  const resized = await sharp(layerBytes, SHARP_INPUT_OPTIONS)
144
- .resize({ width: targetWidth, height: info.height, fit: "inside" })
154
+ .resize({
155
+ width: targetWidth,
156
+ height: info.height,
157
+ fit: "inside",
158
+ // Only ever a sub-pixel correction for a QR; keeps module edges crisp.
159
+ kernel: layer.kind === "qr" ? "nearest" : "lanczos3",
160
+ })
145
161
  .png()
146
162
  .toBuffer({ resolveWithObject: true });
147
163
  const marginPx = Math.round(info.width * (layer.marginPct ?? 0));
@@ -212,7 +228,8 @@ function assertOverlayLayerBounds(layer) {
212
228
  if (!Number.isFinite(layer.widthPct) || layer.widthPct <= 0 || layer.widthPct > 1) {
213
229
  throw new Error(`derivatives-sharp: overlay widthPct ${layer.widthPct} is out of range — must be a finite number in (0, 1].`);
214
230
  }
215
- if (layer.marginPct !== undefined && (layer.marginPct < 0 || layer.marginPct >= 0.5)) {
231
+ if (layer.marginPct !== undefined &&
232
+ (!Number.isFinite(layer.marginPct) || layer.marginPct < 0 || layer.marginPct >= 0.5)) {
216
233
  throw new Error(`derivatives-sharp: overlay marginPct ${layer.marginPct} is out of range — must be within [0, 0.5).`);
217
234
  }
218
235
  if (layer.kind === "qr") {
@@ -278,7 +295,8 @@ export const renderImage = async (input, spec, sourceMimeType) => {
278
295
  // the output.
279
296
  let pipeline = sharp(input, SHARP_INPUT_OPTIONS).rotate();
280
297
  if (spec.blurRegions && spec.blurRegions.length > 0) {
281
- const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
298
+ // PNG intermediate for the same reason as in applyOverlays (no extra lossy generation).
299
+ const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
282
300
  const blurred = await applyBlurRegions(data, info.width, info.height, spec.blurRegions);
283
301
  pipeline = sharp(blurred, SHARP_INPUT_OPTIONS);
284
302
  }
@@ -20,6 +20,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
20
20
  readonly filterable?: boolean;
21
21
  readonly encrypted?: boolean;
22
22
  readonly sensitive?: boolean;
23
+ readonly writeOnly?: boolean;
23
24
  readonly readAsInstruction?: boolean;
24
25
  readonly format?: "email" | "url" | "phone" | "password";
25
26
  readonly default?: string;
@@ -40,6 +41,7 @@ export declare const documentExtractEntity: import("@cosmicdrift/kumiko-types/fi
40
41
  readonly filterable?: boolean;
41
42
  readonly encrypted?: boolean;
42
43
  readonly sensitive?: boolean;
44
+ readonly writeOnly?: boolean;
43
45
  readonly readAsInstruction?: boolean;
44
46
  readonly format?: "email" | "url" | "phone" | "password";
45
47
  readonly default?: string;
@@ -79,6 +81,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
79
81
  readonly filterable?: boolean;
80
82
  readonly encrypted?: boolean;
81
83
  readonly sensitive?: boolean;
84
+ readonly writeOnly?: boolean;
82
85
  readonly readAsInstruction?: boolean;
83
86
  readonly format?: "email" | "url" | "phone" | "password";
84
87
  readonly default?: string;
@@ -99,6 +102,7 @@ export declare const documentExtractsTable: import("@cosmicdrift/kumiko-framewor
99
102
  readonly filterable?: boolean;
100
103
  readonly encrypted?: boolean;
101
104
  readonly sensitive?: boolean;
105
+ readonly writeOnly?: boolean;
102
106
  readonly readAsInstruction?: boolean;
103
107
  readonly format?: "email" | "url" | "phone" | "password";
104
108
  readonly default?: string;
@@ -11,7 +11,7 @@ import { validateDocumentIngestProviderWiring } from "./boot-checks.js";
11
11
  import { documentExtractEntity } from "./entity.js";
12
12
  import { DOCUMENT_INGEST_AGGREGATE_TYPE, DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, DOCUMENT_INGEST_SKIPPED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestRequestedPayloadSchema, documentIngestSkippedPayloadSchema, } from "./events.js";
13
13
  import { forgetExtractOnFileRefDeletedHook, forgetExtractOnFileRefForgottenHook, forgetOrphanedDocumentExtractHook, } from "./forget-extract-with-file-ref.js";
14
- import { EXT_DOCUMENT_INGEST_PROVIDER, resolveDocumentIngestProviders } from "./providers.js";
14
+ import { EXT_DOCUMENT_INGEST_PROVIDER, resolveDocumentIngestProviders, UNCLAIMED_PROVIDER_SENTINEL, } from "./providers.js";
15
15
  import { documentExtractTenantDestroyHook } from "./tenant-destroy-hook.js";
16
16
  const FEATURE_NAME = "document-ingest-foundation";
17
17
  const FILE_REF_CREATED = entityEventName("fileRef", "created");
@@ -123,10 +123,10 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
123
123
  {
124
124
  fromVersion: 1,
125
125
  toVersion: 2,
126
- // "unknown" can never match a real where.provider filter — an
127
- // upcast v1 row reads as unclaimed instead of silently misrouting to
128
- // whichever provider happens to be mounted today.
129
- transform: { default: { provider: "unknown" } },
126
+ // The sentinel can never match a real where.provider filter (providers
127
+ // may not register under it) — an upcast v1 row reads as unclaimed
128
+ // instead of silently misrouting to a currently mounted provider.
129
+ transform: { default: { provider: UNCLAIMED_PROVIDER_SENTINEL } },
130
130
  },
131
131
  ],
132
132
  });
@@ -16,6 +16,7 @@ export type ResolvedDocumentIngestProvider = {
16
16
  readonly name: string;
17
17
  readonly maxFileBytes: number;
18
18
  };
19
+ export declare const UNCLAIMED_PROVIDER_SENTINEL = "unknown";
19
20
  type ExtensionUsages = FeatureDefinition["extensionUsages"];
20
21
  export declare function resolveDocumentIngestProviders(usages: ExtensionUsages): ReadonlyMap<string, ResolvedDocumentIngestProvider>;
21
22
  export declare function listIngestibleMimeTypes(registry: Registry): readonly string[];
@@ -9,6 +9,9 @@ export const documentIngestProviderOptionsSchema = z.object({
9
9
  mimeTypes: z.array(z.string().min(1)).min(1),
10
10
  maxFileBytes: z.number().int().positive(),
11
11
  });
12
+ // Stamped on upcast v1 documentIngest.requested events; a provider may not
13
+ // claim it, or replayed v1 events would route to that provider.
14
+ export const UNCLAIMED_PROVIDER_SENTINEL = "unknown";
12
15
  // Throws on an invalid options shape or two providers claiming the same
13
16
  // normalized mimeType — callers decide whether that's boot-fatal.
14
17
  export function resolveDocumentIngestProviders(usages) {
@@ -16,6 +19,9 @@ export function resolveDocumentIngestProviders(usages) {
16
19
  for (const usage of usages) {
17
20
  if (usage.extensionName !== EXT_DOCUMENT_INGEST_PROVIDER)
18
21
  continue;
22
+ if (usage.entityName === UNCLAIMED_PROVIDER_SENTINEL) {
23
+ throw new Error(`document-ingest-foundation: provider name "${UNCLAIMED_PROVIDER_SENTINEL}" is reserved for upcast legacy events.`);
24
+ }
19
25
  const parsed = documentIngestProviderOptionsSchema.safeParse(usage.options);
20
26
  if (!parsed.success) {
21
27
  throw new Error(`document-ingest-foundation: provider "${usage.entityName}" registered invalid options via ` +
@@ -1,12 +1,12 @@
1
1
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
- import type { DocumentExtractMeta, IngestPage } from "./entity.js";
3
+ import { type DocumentExtractMeta, type IngestPage } from "./entity.js";
4
4
  export type DocumentExtractWriteResult = {
5
5
  readonly kind: "written";
6
6
  readonly documentExtractId: string;
7
7
  } | {
8
8
  readonly kind: "skipped";
9
- readonly reason: "file_ref_deleted";
9
+ readonly reason: "file_ref_deleted" | "already_extracted";
10
10
  };
11
11
  export declare function writeDocumentExtractForLiveFileRef(input: {
12
12
  readonly tenantDb: TenantDb;
@@ -3,6 +3,7 @@
3
3
  // the one place that write must go through to stay race-safe against a
4
4
  // concurrent fileRef delete/forget — see forget-extract-with-file-ref.ts.
5
5
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { documentExtractsTable } from "./entity.js";
6
7
  import { documentExtractExecutor } from "./executor.js";
7
8
  import { isFileRefLive } from "./file-ref-liveness.js";
8
9
  import { writeIngestPages } from "./pages.js";
@@ -13,6 +14,15 @@ export async function writeDocumentExtractForLiveFileRef(input) {
13
14
  if (!(await isFileRefLive(input.tenantDb, input.fileRefId))) {
14
15
  return { kind: "skipped", reason: "file_ref_deleted" };
15
16
  }
17
+ // A redelivered documentIngest.requested, or the one a delete→restore race
18
+ // re-requests while the old extract is still standing, must not create a
19
+ // second extract for the same fileRef.
20
+ const existing = await input.tenantDb.selectMany(documentExtractsTable, {
21
+ fileRefId: input.fileRefId,
22
+ });
23
+ if (existing.length > 0) {
24
+ return { kind: "skipped", reason: "already_extracted" };
25
+ }
16
26
  const result = await documentExtractExecutor.create({
17
27
  fileRefId: input.fileRefId,
18
28
  storageKey: input.storageKey,
@@ -1,4 +1,4 @@
1
- import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { boolean, declareGlobalTenancy, instant, integer, table as pgTable, sql, text, } from "@cosmicdrift/kumiko-framework/db";
3
3
  // Global feature-toggle override state. One row per feature that has ever
4
4
  // been explicitly flipped by an operator. Missing row = "no override,
@@ -26,9 +26,4 @@ export const globalFeatureStateTable = declareGlobalTenancy(pgTable("store_globa
26
26
  // the pgTable made queries type-check while the migration generator stayed
27
27
  // blind to it, failing in prod with "column ... does not exist" while every
28
28
  // mocked-stack test stayed green.
29
- const derivedMeta = asEntityTableMeta(globalFeatureStateTable);
30
- if (!derivedMeta) {
31
- throw new Error("global-feature-state-table: asEntityTableMeta(globalFeatureStateTable) returned undefined " +
32
- "— the pgTable definition no longer round-trips through the unmanaged-table meta builder.");
33
- }
34
- export const globalFeatureStateTableMeta = derivedMeta;
29
+ export const globalFeatureStateTableMeta = requireEntityTableMeta(globalFeatureStateTable, "globalFeatureStateTable");
@@ -1,5 +1,6 @@
1
+ import { type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
1
2
  import type { ToggleSyncSignal } from "./toggle-runtime.js";
2
3
  export type RedisToggleSyncSignal = ToggleSyncSignal & {
3
4
  close(): Promise<void>;
4
5
  };
5
- export declare function createRedisToggleSyncSignal(redisUrl: string): RedisToggleSyncSignal;
6
+ export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions): RedisToggleSyncSignal;
@@ -1,4 +1,4 @@
1
- import { createRedisPubSubSignal } from "@cosmicdrift/kumiko-framework/redis";
1
+ import { createRedisPubSubSignal, } from "@cosmicdrift/kumiko-framework/redis";
2
2
  // Single fixed channel — unlike the SSE broker there's no per-tenant/
3
3
  // per-user variance to multiplex, every toggle flip is global.
4
4
  const TOGGLE_SYNC_CHANNEL = "kumiko:feature-toggles:cache-sync";
@@ -15,11 +15,12 @@ function isTogglePayload(value) {
15
15
  // replicas > 1 is expected to set REDIS_URL, so app-boot code should build
16
16
  // this only when REDIS_URL is present and pass it into
17
17
  // createFeatureToggleRuntime.
18
- export function createRedisToggleSyncSignal(redisUrl) {
18
+ export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
19
19
  const signal = createRedisPubSubSignal({
20
20
  redisUrl,
21
21
  channelPattern: TOGGLE_SYNC_CHANNEL,
22
22
  label: "feature-toggles",
23
+ ...(clientOptions ? { clientOptions } : {}),
23
24
  });
24
25
  return {
25
26
  publish(featureName, enabled) {
@@ -36,7 +36,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
36
36
  r.requires("secrets");
37
37
  r.requires("file-foundation");
38
38
  const secretAccessKey = r.secret("s3.secretAccessKey", {
39
- label: { en: "S3 Secret Access Key" },
39
+ label: { en: "S3 secret access key" },
40
40
  hint: {
41
41
  en: "Private half of the S3 key pair. Hetzner calls it 'Secret Key', AWS calls it 'Secret Access Key'.",
42
42
  },
@@ -13,6 +13,7 @@
13
13
  import { defineFeature, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import { sweepOrphanedDerivativesJob, sweepOrphanedDerivativesPayloadSchema, } from "./handlers/sweep-orphaned-derivatives.job.js";
15
15
  import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js";
16
+ const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON = "iterates every tenant and checks fileRef owners per tenant";
16
17
  export function createFilesTenantDataFeature() {
17
18
  return defineFeature("files-tenant-data", (r) => {
18
19
  r.describe("GDPR coverage for the `files` feature's `fileRef` entity: tenant-destroy row purge (EXT_TENANT_DATA) and full storage-prefix binary wipe (EXT_STORAGE_PROVIDER), plus a manual backfill/GC job that sweeps derivatives orphaned by a forget/tenant-destroy that ran before #2461 wired binary cleanup into those flows. Requires `tenant-lifecycle`, optionalRequires `files`.");
@@ -31,9 +32,9 @@ export function createFilesTenantDataFeature() {
31
32
  concurrency: "skip",
32
33
  schema: sweepOrphanedDerivativesPayloadSchema,
33
34
  escapeHatch: {
34
- reason: "iterates every tenant and checks fileRef owners per tenant",
35
+ reason: SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON,
35
36
  },
36
- handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw("iterates every tenant and checks fileRef owners per tenant")),
37
+ handler: (payload, ctx) => sweepOrphanedDerivativesJob(payload, ctx, ctx.db.unsafeRaw(SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON)),
37
38
  });
38
39
  });
39
40
  }