@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.336.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 (258) 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/auth-email-password/handlers/change-password.write.js +2 -0
  12. package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
  13. package/dist/auth-email-password/handlers/login.write.js +2 -0
  14. package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
  15. package/dist/auth-email-password/i18n.js +2 -0
  16. package/dist/auth-email-password/web/auth-client.d.ts +1 -0
  17. package/dist/auth-email-password/web/auth-client.js +1 -1
  18. package/dist/auth-email-password/web/auth-gate.js +6 -8
  19. package/dist/auth-email-password/web/index.d.ts +1 -1
  20. package/dist/auth-email-password/web/index.js +1 -1
  21. package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
  22. package/dist/auth-email-password/web/login-screen.js +1 -1
  23. package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
  24. package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
  25. package/dist/auth-email-password/web/session.d.ts +4 -0
  26. package/dist/auth-email-password/web/session.js +15 -0
  27. package/dist/auth-mfa/feature.js +3 -2
  28. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
  29. package/dist/auth-mfa/handlers/status.query.js +1 -0
  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/web/mfa-client.d.ts +1 -1
  35. package/dist/auth-mfa/web/mfa-client.js +4 -10
  36. package/dist/auth-mfa-user-data/hooks.js +4 -2
  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/handlers/switch-plan.write.js +5 -8
  41. package/dist/cap-counter/book-cap-usage.js +20 -5
  42. package/dist/cap-counter/enforce-cap.js +9 -3
  43. package/dist/cap-counter/feature.js +3 -2
  44. package/dist/cap-counter/i18n.d.ts +1 -0
  45. package/dist/cap-counter/i18n.js +6 -5
  46. package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
  47. package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
  48. package/dist/cap-overview/i18n.js +5 -0
  49. package/dist/cap-overview/web/cap-cards-panel.js +4 -1
  50. package/dist/cap-overview/web/cap-usage-bar.js +1 -1
  51. package/dist/channel-email/email-channel.js +12 -1
  52. package/dist/channel-in-app/tables.js +2 -6
  53. package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
  54. package/dist/config/db/queries/resolver.js +3 -1
  55. package/dist/config/handlers/set.write.js +8 -4
  56. package/dist/config/web/i18n.js +1 -0
  57. package/dist/config/write-helpers.d.ts +1 -1
  58. package/dist/config/write-helpers.js +11 -5
  59. package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
  60. package/dist/custom-fields/constants.js +1 -4
  61. package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
  62. package/dist/data-retention/run-retention-cleanup.js +100 -44
  63. package/dist/delivery/handlers/log.query.js +33 -24
  64. package/dist/delivery/types.d.ts +2 -1
  65. package/dist/delivery/types.js +7 -0
  66. package/dist/delivery/unsubscribe.js +5 -11
  67. package/dist/derivatives-sharp/render.js +27 -9
  68. package/dist/document-ingest-foundation/feature.js +5 -5
  69. package/dist/document-ingest-foundation/providers.d.ts +1 -0
  70. package/dist/document-ingest-foundation/providers.js +6 -0
  71. package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
  72. package/dist/document-ingest-foundation/write-document-extract.js +10 -0
  73. package/dist/feature-toggles/global-feature-state-table.js +2 -7
  74. package/dist/feature-toggles/toggle-runtime.d.ts +1 -0
  75. package/dist/feature-toggles/toggle-runtime.js +16 -2
  76. package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
  77. package/dist/feature-toggles/toggle-sync-signal.js +3 -2
  78. package/dist/files-tenant-data/index.js +3 -2
  79. package/dist/folders/handlers/clear-folder.write.js +4 -11
  80. package/dist/folders/handlers/set-folder.write.js +4 -10
  81. package/dist/form-draft/feature.js +3 -2
  82. package/dist/form-draft/handlers/discard.write.js +7 -8
  83. package/dist/form-draft/handlers/get.query.js +7 -1
  84. package/dist/foundation-shared/host-allowlist.d.ts +4 -0
  85. package/dist/foundation-shared/host-allowlist.js +14 -0
  86. package/dist/foundation-shared/index.d.ts +1 -0
  87. package/dist/foundation-shared/index.js +1 -0
  88. package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
  89. package/dist/foundation-shared/mail-host-policy.js +3 -13
  90. package/dist/inbound-mail-foundation/connect-routes.js +7 -5
  91. package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
  92. package/dist/jobs/db/queries/stale-run-sweep.js +22 -15
  93. package/dist/jobs/handlers/list.query.d.ts +7 -2
  94. package/dist/jobs/handlers/list.query.js +9 -2
  95. package/dist/jobs/handlers/retry.write.js +3 -2
  96. package/dist/jobs/handlers/tenant-failures.query.js +3 -9
  97. package/dist/jobs/job-run-logger.js +18 -2
  98. package/dist/jobs/tenant-job-failure-table.js +2 -6
  99. package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
  100. package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
  101. package/dist/ledger/subject-reference.d.ts +4 -0
  102. package/dist/ledger/subject-reference.js +7 -0
  103. package/dist/legal-pages/feature.js +8 -1
  104. package/dist/managed-pages/handlers/branding.query.js +1 -1
  105. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  106. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
  107. package/dist/metrics/compute.js +7 -1
  108. package/dist/metrics/window.js +5 -0
  109. package/dist/notes-history/handlers/add-note.write.js +4 -9
  110. package/dist/notes-history-user-data/hooks.js +28 -14
  111. package/dist/personal-access-tokens/constants.d.ts +1 -0
  112. package/dist/personal-access-tokens/constants.js +1 -0
  113. package/dist/personal-access-tokens/feature.js +2 -2
  114. package/dist/personal-access-tokens/handlers/create.write.js +2 -2
  115. package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
  116. package/dist/personal-access-tokens/i18n.js +12 -13
  117. package/dist/personal-access-tokens/revoke-for-user.js +12 -1
  118. package/dist/personal-access-tokens/schema/api-token.js +2 -1
  119. package/dist/personal-access-tokens/screens.js +5 -1
  120. package/dist/rate-limiting/feature.js +2 -0
  121. package/dist/rate-limiting/handlers/bucket-access.js +15 -7
  122. package/dist/rate-limiting/i18n.d.ts +10 -0
  123. package/dist/rate-limiting/i18n.js +10 -0
  124. package/dist/secrets/feature.js +3 -2
  125. package/dist/seo/handlers/seo-config.query.js +1 -1
  126. package/dist/sessions/constants.d.ts +1 -0
  127. package/dist/sessions/constants.js +1 -0
  128. package/dist/sessions/feature.js +5 -3
  129. package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
  130. package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
  131. package/dist/sessions/handlers/revoke.write.js +2 -2
  132. package/dist/sessions/schema/user-session.js +2 -1
  133. package/dist/sessions/screens.js +5 -1
  134. package/dist/sessions/session-callbacks.js +88 -77
  135. package/dist/shared/assert-erased.d.ts +2 -1
  136. package/dist/shared/assert-erased.js +12 -4
  137. package/dist/shared/index.d.ts +2 -2
  138. package/dist/shared/index.js +2 -2
  139. package/dist/shared/lockout-counter.js +1 -1
  140. package/dist/shared/parent-visibility.d.ts +5 -0
  141. package/dist/shared/parent-visibility.js +17 -1
  142. package/dist/shared/row-bound-grant.js +20 -9
  143. package/dist/shared/single-use-token-store.js +5 -13
  144. package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
  145. package/dist/step-dispatcher/webhook-runner.js +18 -17
  146. package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
  147. package/dist/subscription-mollie/plugin-methods.js +7 -1
  148. package/dist/subscription-mollie/verify-webhook.js +6 -0
  149. package/dist/subscription-stripe/plugin-methods.js +25 -6
  150. package/dist/subscription-stripe/verify-webhook.js +13 -6
  151. package/dist/tags/handlers/assign-tag.write.js +4 -10
  152. package/dist/tags/handlers/remove-tag.write.js +4 -11
  153. package/dist/template-resolver/api.d.ts +2 -1
  154. package/dist/template-resolver/api.js +1 -1
  155. package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
  156. package/dist/template-resolver/handlers/by-slug.query.js +2 -2
  157. package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
  158. package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
  159. package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
  160. package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
  161. package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
  162. package/dist/template-resolver/handlers/collection-shared.js +1 -1
  163. package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
  164. package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
  165. package/dist/template-resolver/handlers/list.query.d.ts +1 -1
  166. package/dist/template-resolver/handlers/list.query.js +1 -1
  167. package/dist/template-resolver/seeding.js +41 -39
  168. package/dist/template-resolver/table.d.ts +8 -5
  169. package/dist/template-resolver/testing.js +2 -2
  170. package/dist/template-resolver/user-content-table.d.ts +2 -5
  171. package/dist/template-resolver/web/client-plugin.d.ts +1 -1
  172. package/dist/template-resolver-user-data/hooks.js +1 -1
  173. package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
  174. package/dist/tenant/cancel-pending-invitation.js +22 -0
  175. package/dist/tenant/feature.js +4 -2
  176. package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
  177. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  178. package/dist/tenant/handlers/member-directory.query.js +17 -4
  179. package/dist/tenant/handlers/remove-member.write.js +9 -14
  180. package/dist/tenant/handlers/team-list.query.js +2 -2
  181. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  182. package/dist/tenant/handlers/tenant-directory.query.js +13 -3
  183. package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
  184. package/dist/tenant/handlers/update-member-roles.write.js +28 -12
  185. package/dist/tenant/i18n.js +4 -0
  186. package/dist/tenant/index.d.ts +1 -1
  187. package/dist/tenant/index.js +1 -1
  188. package/dist/tenant/last-tenant-admin.d.ts +1 -1
  189. package/dist/tenant/last-tenant-admin.js +5 -3
  190. package/dist/tenant/seeding.d.ts +4 -0
  191. package/dist/tenant/seeding.js +9 -2
  192. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  193. package/dist/tenant-lifecycle/boot-checks.js +27 -1
  194. package/dist/tenant-lifecycle/feature.js +2 -1
  195. package/dist/tenant-lifecycle/stages.js +2 -2
  196. package/dist/tier-engine/feature.js +3 -2
  197. package/dist/user/command-schemas.d.ts +2 -2
  198. package/dist/user/handlers/create.write.d.ts +1 -1
  199. package/dist/user/handlers/create.write.js +8 -2
  200. package/dist/user/handlers/detail.query.d.ts +1 -0
  201. package/dist/user/handlers/update.write.d.ts +1 -1
  202. package/dist/user/handlers/update.write.js +8 -2
  203. package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
  204. package/dist/user-data-rights/feature.js +22 -9
  205. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
  206. package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
  207. package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
  208. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  209. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  210. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
  211. package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
  212. package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
  213. package/dist/user-data-rights/run-export-jobs.js +74 -41
  214. package/dist/user-data-rights/run-forget-cleanup.js +2 -2
  215. package/dist/user-data-rights/run-user-export.js +2 -2
  216. package/dist/user-data-rights-defaults/hooks/config-value.userdata-hook.js +4 -2
  217. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +8 -3
  218. package/dist/user-data-rights-defaults/hooks/notification-preference.userdata-hook.js +4 -2
  219. package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +4 -2
  220. package/dist/user-profile/feature.js +6 -1
  221. package/dist/user-profile/handlers/change-email.write.js +2 -0
  222. package/dist/workflow-runner/db/queries/due-runs.js +6 -9
  223. package/dist/workflow-runner/event-subscriber.js +29 -9
  224. package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
  225. package/dist/workflow-runner/pending-projection.js +8 -1
  226. package/package.json +11 -9
  227. package/src/agent-tools/changes.json +12 -0
  228. package/src/auth-email-password/changes.json +18 -0
  229. package/src/auth-mfa/changes.json +6 -0
  230. package/src/billing-foundation/changes.json +7 -0
  231. package/src/cap-counter/changes.json +13 -0
  232. package/src/cap-overview/changes.json +12 -0
  233. package/src/channel-email/changes.json +6 -0
  234. package/src/config/changes.json +6 -0
  235. package/src/crypto-shredding/changes.json +6 -0
  236. package/src/data-retention/changes.json +12 -0
  237. package/src/delivery/changes.json +13 -0
  238. package/src/derivatives-sharp/changes.json +11 -0
  239. package/src/document-ingest-foundation/changes.json +12 -0
  240. package/src/feature-toggles/changes.json +5 -0
  241. package/src/inbound-mail-foundation/changes.json +12 -0
  242. package/src/jobs/changes.json +18 -0
  243. package/src/legal-pages/changes.json +8 -1
  244. package/src/managed-pages/changes.json +5 -0
  245. package/src/metrics/changes.json +6 -0
  246. package/src/personal-access-tokens/changes.json +5 -0
  247. package/src/rate-limiting/changes.json +5 -0
  248. package/src/secrets/changes.json +7 -0
  249. package/src/sessions/changes.json +11 -0
  250. package/src/shared/changes.json +6 -0
  251. package/src/step-dispatcher/changes.json +5 -0
  252. package/src/subscription-mollie/changes.json +7 -0
  253. package/src/subscription-stripe/changes.json +11 -0
  254. package/src/template-resolver/changes.json +7 -0
  255. package/src/tenant/changes.json +29 -0
  256. package/src/user-data-rights/changes.json +22 -0
  257. package/src/user-data-rights-defaults/changes.json +5 -0
  258. package/src/workflow-runner/changes.json +6 -0
@@ -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");
@@ -9,6 +9,7 @@ export declare class GlobalFeatureToggleRuntime {
9
9
  private readonly registry;
10
10
  private readonly syncSignal?;
11
11
  private snapshot;
12
+ private appliedDuringLoad;
12
13
  constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined);
13
14
  initialize(): Promise<void>;
14
15
  refresh(): Promise<void>;
@@ -17,6 +17,9 @@ export class GlobalFeatureToggleRuntime {
17
17
  registry;
18
18
  syncSignal;
19
19
  snapshot = new Map();
20
+ // Flips applied while a snapshot load is in flight. The load's SELECT may have
21
+ // started before the flip committed, so its rows can be older than these.
22
+ appliedDuringLoad;
20
23
  constructor(db, registry, syncSignal) {
21
24
  this.db = db;
22
25
  this.registry = registry;
@@ -30,8 +33,18 @@ export class GlobalFeatureToggleRuntime {
30
33
  });
31
34
  }
32
35
  async initialize() {
33
- const rows = await selectMany(this.db, globalFeatureStateTable);
34
- this.snapshot = new Map(rows.map((r) => [r.featureName, r.enabled]));
36
+ const pendingFlips = new Map();
37
+ this.appliedDuringLoad = pendingFlips;
38
+ try {
39
+ const rows = await selectMany(this.db, globalFeatureStateTable);
40
+ this.snapshot = new Map([
41
+ ...rows.map((r) => [r.featureName, r.enabled]),
42
+ ...pendingFlips,
43
+ ]);
44
+ }
45
+ finally {
46
+ this.appliedDuringLoad = undefined;
47
+ }
35
48
  }
36
49
  // Re-read the full snapshot. Called from the set-handler after a
37
50
  // successful write — cheap point-update would be an optimisation but
@@ -46,6 +59,7 @@ export class GlobalFeatureToggleRuntime {
46
59
  // lock. Kept alongside refresh() so both options are explicit.
47
60
  apply(featureName, enabled) {
48
61
  this.snapshot.set(featureName, enabled);
62
+ this.appliedDuringLoad?.set(featureName, enabled);
49
63
  }
50
64
  // Called by the toggle-cache-sync MSP handler (feature.ts) — the one
51
65
  // process whose shared cursor won a given toggle-set event. With a
@@ -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) {
@@ -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
  }
@@ -1,5 +1,4 @@
1
- import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
- import { joinRowParentIsVisible } from "../../shared/index.js";
1
+ import { denyUnlessJoinRowParentVisible } from "../../shared/index.js";
3
2
  import { folderAssignmentAggregateId } from "../aggregate-id.js";
4
3
  import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
5
4
  import { folderAssignmentExecutor } from "../executor.js";
@@ -19,15 +18,9 @@ export function createClearFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
19
18
  description: "Unfiles a host entity so it sits in no folder at all, reporting success when it was already unfiled; use it to take an entity out of its folder without touching the folder itself.",
20
19
  handler: async (event, ctx) => {
21
20
  const payload = event.payload; // @cast-boundary engine-payload
22
- // entityType/entityId are client input: the caller must be able to see
23
- // the host row through that entity's own read path (tenant scope plus its
24
- // `access.read` ownership) before its assignment row may be written.
25
- // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
26
- // an invisible parent apart from a missing assignment or an unknown folder —
27
- // every path answers with the same NotFoundError.
28
- if (!(await joinRowParentIsVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db))) {
29
- return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
30
- }
21
+ const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db);
22
+ if (denied)
23
+ return denied;
31
24
  const id = folderAssignmentAggregateId(event.user.tenantId, payload.entityType, payload.entityId);
32
25
  const existing = await folderAssignmentExecutor.detail({ id }, event.user, ctx.db);
33
26
  if (!existing) {
@@ -1,5 +1,5 @@
1
1
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
- import { joinRowParentIsVisible } from "../../shared/index.js";
2
+ import { denyUnlessJoinRowParentVisible } from "../../shared/index.js";
3
3
  import { folderAssignmentAggregateId } from "../aggregate-id.js";
4
4
  import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
5
5
  import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
@@ -35,15 +35,9 @@ export function createSetFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
35
35
  description: "Files a host entity into one existing folder and moves it out of whatever folder it was in, since an entity belongs to at most one folder; use it for both the first filing and every later move.",
36
36
  handler: async (event, ctx) => {
37
37
  const payload = event.payload; // @cast-boundary engine-payload
38
- // entityType/entityId are client input: the caller must be able to see
39
- // the host row through that entity's own read path (tenant scope plus its
40
- // `access.read` ownership) before its assignment row may be written.
41
- // Checked FIRST, ahead of every lookup below, so a denied caller can't tell
42
- // an invisible parent apart from a missing assignment or an unknown folder —
43
- // every path answers with the same NotFoundError.
44
- if (!(await joinRowParentIsVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db))) {
45
- return writeFailure(new NotFoundError(payload.entityType, payload.entityId));
46
- }
38
+ const denied = await denyUnlessJoinRowParentVisible(ctx.registry, "folder-assignment", payload, event.user, ctx.db);
39
+ if (denied)
40
+ return denied;
47
41
  const id = folderAssignmentAggregateId(event.user.tenantId, payload.entityType, payload.entityId);
48
42
  const folder = await folderExecutor.detail({ id: payload.folderId }, event.user, ctx.db);
49
43
  if (!folder)
@@ -12,6 +12,7 @@ import { discardDraftWrite } from "./handlers/discard.write.js";
12
12
  import { getDraftQuery } from "./handlers/get.query.js";
13
13
  import { listDraftsQuery } from "./handlers/list.query.js";
14
14
  import { saveDraftWrite } from "./handlers/save.write.js";
15
+ const CLEANUP_ESCAPE_HATCH_REASON = "purges stale drafts of every tenant";
15
16
  function registerFormDraft(r) {
16
17
  r.describe("Per-user, per-tenant working copy of an in-progress form, saved BEFORE the real domain entity exists. Owns one event-sourced entity, `form-draft` (`read_form_drafts`), keyed by a caller-assigned draftKey (typically screenId + optional hostEntityId), unique per (tenant, owner, draftKey). `save` upserts the draft blob ({ values, stepIndex, savedAt } — savedAt stamped server-side), `discard` deletes it (called once the real submit succeeds), `get` resumes it, `list` finds a user's open drafts for a given screenId (draftKey prefix match) — the fallback when a client-generated draftId is lost. Ownership is enforced by a per-row owner filter in every handler, not by roles — a foreign user's save/discard/get/list for someone else's draftKey never sees or touches that row. Never holds anything that already lives in a domain stream; the consuming app is responsible for discarding once the domain write succeeds. A daily cron job hard-deletes drafts past a configurable retention window (`form-draft:config:retention-days`, SystemAdmin-writable, default 30 days).");
17
18
  r.uiHints({
@@ -36,8 +37,8 @@ function registerFormDraft(r) {
36
37
  name: "cleanup",
37
38
  trigger: { cron: "0 3 * * *" },
38
39
  concurrency: "skip",
39
- escapeHatch: { reason: "purges stale drafts of every tenant" },
40
- handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw("purges stale drafts of every tenant")),
40
+ escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
41
+ handler: (payload, ctx) => cleanupDraftsJob(payload, ctx, ctx.db.unsafeRaw(CLEANUP_ESCAPE_HATCH_REASON)),
41
42
  });
42
43
  }
43
44
  export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
@@ -73,18 +73,17 @@ export const discardDraftWrite = defineWriteHandler({
73
73
  const keys = ownedRefs.map((ref) => ref.storageKey);
74
74
  const log = ctx.log;
75
75
  const schedule = ctx.scheduleAfterCommit;
76
+ const release = async () => {
77
+ await releaseDraftFileRefs(keys, async (key) => {
78
+ await files.ref(key).delete();
79
+ }, log);
80
+ };
76
81
  if (schedule) {
77
- schedule(async () => {
78
- await releaseDraftFileRefs(keys, async (key) => {
79
- await files.ref(key).delete();
80
- }, log);
81
- });
82
+ schedule(release);
82
83
  }
83
84
  else {
84
85
  // Unit/harness paths without a commit sink — best-effort inline.
85
- await releaseDraftFileRefs(keys, async (key) => {
86
- await files.ref(key).delete();
87
- }, log);
86
+ await release();
88
87
  }
89
88
  }
90
89
  return { isSuccess: true, data: { discarded: true } };
@@ -16,6 +16,12 @@ export const getDraftQuery = defineQueryHandler({
16
16
  if (!row)
17
17
  return { draft: null };
18
18
  const parsed = formDraftBlobSchema.safeParse(row.draft);
19
- return { draft: parsed.success ? parsed.data : null };
19
+ if (!parsed.success) {
20
+ // Schema evolution or a corrupt jsonb row: surface it to operators
21
+ // instead of silently making the user's draft vanish.
22
+ ctx.log?.warn?.(`[form-draft:get] stored blob for draftKey=${query.payload.draftKey} failed schema, treating as absent: ${parsed.error.message}`);
23
+ return { draft: null };
24
+ }
25
+ return { draft: parsed.data };
20
26
  },
21
27
  });
@@ -0,0 +1,4 @@
1
+ /** Parses a comma-separated operator host allowlist env var. Never throws —
2
+ * an unset or empty value just means no bypass. */
3
+ export declare function readHostAllowlistFromEnv(envVar: string, env?: Readonly<Record<string, string | undefined>>): readonly string[];
4
+ export declare function isHostAllowlisted(host: string, allowlist: readonly string[]): boolean;
@@ -0,0 +1,14 @@
1
+ /** Parses a comma-separated operator host allowlist env var. Never throws —
2
+ * an unset or empty value just means no bypass. */
3
+ export function readHostAllowlistFromEnv(envVar, env = process.env) {
4
+ const raw = env[envVar];
5
+ if (!raw)
6
+ return [];
7
+ return raw
8
+ .split(",")
9
+ .map((host) => host.trim())
10
+ .filter((host) => host.length > 0);
11
+ }
12
+ export function isHostAllowlisted(host, allowlist) {
13
+ return allowlist.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
14
+ }
@@ -1,2 +1,3 @@
1
1
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
2
+ export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
2
3
  export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
@@ -1,4 +1,5 @@
1
1
  // Public API of foundation-shared — utilities consumed by the per-tenant
2
2
  // Foundation packages (ai-foundation, mail-foundation, file-foundation).
3
3
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
4
+ export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
4
5
  export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
@@ -1,7 +1,5 @@
1
1
  import type { lookup } from "node:dns/promises";
2
2
  export declare const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
3
- /** Parses the comma-separated operator allowlist env var. Never throws —
4
- * an unset or empty value just means no bypass. */
5
3
  export declare function readAllowedPrivateMailHostsFromEnv(env?: Readonly<Record<string, string | undefined>>): readonly string[];
6
4
  export type MailHostGuardOptions = {
7
5
  readonly allowedPrivateMailHosts?: readonly string[];
@@ -16,23 +16,13 @@
16
16
  // conflict check tripping when both features are mounted together.
17
17
  import { isIP } from "node:net";
18
18
  import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
19
+ import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
19
20
  export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
20
- /** Parses the comma-separated operator allowlist env var. Never throws —
21
- * an unset or empty value just means no bypass. */
22
21
  export function readAllowedPrivateMailHostsFromEnv(env = process.env) {
23
- const raw = env[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
24
- if (!raw)
25
- return [];
26
- return raw
27
- .split(",")
28
- .map((host) => host.trim())
29
- .filter((host) => host.length > 0);
30
- }
31
- function isAllowedPrivateMailHost(host, allowed) {
32
- return allowed.some((candidate) => candidate.toLowerCase() === host.toLowerCase());
22
+ return readHostAllowlistFromEnv(MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, env);
33
23
  }
34
24
  export async function resolveMailConnectTarget(host, options = {}) {
35
- if (isAllowedPrivateMailHost(host, options.allowedPrivateMailHosts ?? [])) {
25
+ if (isHostAllowlisted(host, options.allowedPrivateMailHosts ?? [])) {
36
26
  return { host };
37
27
  }
38
28
  const resolved = await resolvePublicHostname(host, options.lookupFn);
@@ -73,6 +73,12 @@ export function createInboundMailConnectRoutes(options) {
73
73
  verify: async (request) => verifyCallbackRequest(request, options.stateSecret),
74
74
  handler: async (c, verified, deps) => {
75
75
  const { code, state } = verified;
76
+ // Checked before exchangeCode/connectAccount: the code is single-use and
77
+ // an account created without a persistable refresh token is an orphan.
78
+ // `deps.secrets` is optional on SignatureExtraRouteDeps.
79
+ if (!deps.secrets) {
80
+ return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
81
+ }
76
82
  const providerCtx = { registry: deps.registry, secrets: deps.secrets, config: undefined };
77
83
  let plugin;
78
84
  try {
@@ -123,11 +129,7 @@ export function createInboundMailConnectRoutes(options) {
123
129
  }
124
130
  // Refresh-token into the per-account secret slot (Slot = accountId).
125
131
  // Access tokens (~1h) are NEVER persisted — refresh-before-poll in the
126
- // sync path. `deps.secrets` is optional on SignatureExtraRouteDeps —
127
- // without it the token would be silently lost, so fail loud instead.
128
- if (!deps.secrets) {
129
- return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
130
- }
132
+ // sync path.
131
133
  await deps.secrets.set(state.tenantId, inboundCredentialSecretKey(accountId), tokens.refreshToken, {
132
134
  redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
133
135
  hint: `OAuth refresh token for inbound mail account ${accountId}`,
@@ -121,7 +121,8 @@ export function createInboundMailSupervisor(deps) {
121
121
  return null; // korrupter Cursor = wie kein Cursor → Backfill, Dedup fängt Dubletten
122
122
  }
123
123
  }
124
- async function saveCursor(accountId, cursor) {
124
+ async function saveCursor(account, cursor) {
125
+ const accountId = account.id;
125
126
  const now = Temporal.Now.instant().toString();
126
127
  const serialized = JSON.stringify(cursor);
127
128
  const existing = await fetchOne(deps.db, syncCursorTable, {
@@ -135,6 +136,8 @@ export function createInboundMailSupervisor(deps) {
135
136
  }
136
137
  await insertOne(deps.db, syncCursorTable, {
137
138
  id: crypto.randomUUID(),
139
+ // Direct-write store: no executor stamps the NOT NULL tenant_id for us.
140
+ tenantId: account.tenantId,
138
141
  accountId,
139
142
  scope: CURSOR_SCOPE,
140
143
  cursor: serialized,
@@ -227,7 +230,7 @@ export function createInboundMailSupervisor(deps) {
227
230
  maxMessages: budget,
228
231
  });
229
232
  await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
230
- await saveCursor(account.id, result.nextCursor);
233
+ await saveCursor(account, result.nextCursor);
231
234
  cursor = result.nextCursor;
232
235
  budget -= result.messages.length;
233
236
  if (!result.hasMore || budget <= 0)
@@ -267,51 +270,52 @@ export function createInboundMailSupervisor(deps) {
267
270
  // lifecycle — it keeps renewing across reconnect backoff, not just while
268
271
  // `plugin.watch()` is actually connected, so a flaky IMAP link doesn't
269
272
  // make this worker lose the account to a peer mid-backoff.
270
- // kumiko-lint-ignore complexity-budget watch lease renew backoff/re-acquire loop
271
273
  function scheduleRenew(account, state, generation) {
272
274
  state.renewTimer = setTimeout(() => {
273
- // kumiko-lint-ignore complexity-budget renew timer callback (renew/backoff/re-acquire)
274
- void (async () => {
275
- state.renewTimer = null;
276
- // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
277
- if (!running || state.generation !== generation || !state.lockToken || !deps.lock)
278
- return;
279
- let renewed;
280
- try {
281
- renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
282
- }
283
- catch (err) {
284
- log(`inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
285
- state.renewFailures += 1;
286
- // After failures spanning a full lease TTL, tear down — otherwise a
287
- // peer can acquire the expired key while we still hold an IDLE conn.
288
- if (state.renewFailures * renewIntervalMs >= leaseTtlSeconds * 1000) {
289
- log(`inbound-mail: watch lease renew for account ${account.id} failed across TTL — tearing down local watcher`);
290
- state.lockToken = null;
291
- await stopWatcher(account.id);
292
- // skip: local watcher torn down after renew failures spanning a full lease TTL.
293
- return;
294
- }
295
- if (running && state.generation === generation && state.lockToken) {
296
- scheduleRenew(account, state, generation);
297
- }
298
- // skip: renew already rescheduled above (or conditions no longer hold) — nothing left to do this tick.
299
- return;
300
- }
301
- state.renewFailures = 0;
302
- if (!renewed) {
303
- log(`inbound-mail: watch lease for account ${account.id} lost — tearing down local watcher`);
304
- state.lockToken = null;
305
- await stopWatcher(account.id);
306
- // skip: watcher already torn down by stopWatcher() above — nothing left to do.
307
- return;
308
- }
309
- if (running && state.generation === generation && state.lockToken) {
310
- scheduleRenew(account, state, generation);
311
- }
312
- })();
275
+ void renewLeaseOnce(account, state, generation);
313
276
  }, renewIntervalMs);
314
277
  }
278
+ async function tearDownAfterRenewFailures(account, state, reason) {
279
+ log(`inbound-mail: watch lease ${reason} for account ${account.id} — tearing down local watcher`);
280
+ state.lockToken = null;
281
+ await stopWatcher(account.id);
282
+ }
283
+ async function renewLeaseOnce(account, state, generation) {
284
+ state.renewTimer = null;
285
+ // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
286
+ if (!running || state.generation !== generation || !state.lockToken || !deps.lock)
287
+ return;
288
+ let renewed;
289
+ try {
290
+ renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
291
+ }
292
+ catch (err) {
293
+ log(`inbound-mail: watch lease renew for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`);
294
+ state.renewFailures += 1;
295
+ // After failures spanning a full lease TTL, tear down — otherwise a
296
+ // peer can acquire the expired key while we still hold an IDLE conn.
297
+ if (state.renewFailures * renewIntervalMs >= leaseTtlSeconds * 1000) {
298
+ await tearDownAfterRenewFailures(account, state, "renew failed across TTL");
299
+ // skip: connection torn down, no reschedule
300
+ return;
301
+ }
302
+ rescheduleIfStillHeld(account, state, generation);
303
+ // skip: renew failed but TTL not yet exhausted, retry already scheduled
304
+ return;
305
+ }
306
+ state.renewFailures = 0;
307
+ if (!renewed) {
308
+ await tearDownAfterRenewFailures(account, state, "lost");
309
+ // skip: lease lost and torn down, nothing to reschedule
310
+ return;
311
+ }
312
+ rescheduleIfStillHeld(account, state, generation);
313
+ }
314
+ function rescheduleIfStillHeld(account, state, generation) {
315
+ if (running && state.generation === generation && state.lockToken) {
316
+ scheduleRenew(account, state, generation);
317
+ }
318
+ }
315
319
  // kumiko-lint-ignore complexity-budget yield-point stale-map check after acquire is intentional (#2460)
316
320
  async function acquireWatchLease(account, state) {
317
321
  if (!deps.lock || state.lockToken)
@@ -12,30 +12,37 @@
12
12
  // the run becomes retriable via the existing jobs:write:retry gate
13
13
  // (retry.write.ts only allows retry from "failed").
14
14
  import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { mapWithConcurrency } from "../../../shared/index.js";
15
16
  import { encryptFailureError } from "../../job-run-logger.js";
16
17
  import { jobRunsTable } from "../../job-run-table.js";
18
+ const KMS_POOL_CONCURRENCY = 4;
17
19
  export const STALE_JOB_RUN_ERROR = "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
18
20
  export async function markStaleJobRunsFailed(db, timeoutHours) {
19
21
  const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
20
22
  const now = Temporal.Now.instant();
21
23
  // Fetch the matched rows first: `error` carries the same per-triggering-
22
24
  // user subject annotation as job-run-logger.ts's onJobFailed path
23
- // (personal: { of: "triggeredById" } on job-run-table.ts), and a batch
24
- // updateMany can't encrypt per-row — different matched runs can belong to
25
- // different users, each under their own DEK. One updateMany per row keeps
26
- // this crash-recovery sweep's write on the same encrypted footing as the
27
- // normal failure path instead of writing STALE_JOB_RUN_ERROR in the clear.
25
+ // (personal: { of: "triggeredById" } on job-run-table.ts), so it is encrypted
26
+ // under that subject's DEK. Runs are grouped by subject: one encrypt and one
27
+ // updateMany per subject instead of per run.
28
28
  const stale = await selectMany(db, jobRunsTable, {
29
29
  status: "running",
30
30
  startedAt: { lt: cutoff },
31
31
  });
32
+ const idsBySubject = new Map();
33
+ for (const run of stale) {
34
+ const ids = idsBySubject.get(run.triggeredById);
35
+ if (ids)
36
+ ids.push(run.id);
37
+ else
38
+ idsBySubject.set(run.triggeredById, [run.id]);
39
+ }
32
40
  // duration is deliberately left untouched: we don't know when the run
33
41
  // actually died, only that it crossed the timeout, so recording a
34
42
  // duration would misrepresent it as measured. detail-screen/list-screen
35
43
  // both already render a null duration as "—".
36
- let runsMarkedFailed = 0;
37
- for (const run of stale) {
38
- const encryptedError = await encryptFailureError(STALE_JOB_RUN_ERROR, run.triggeredById);
44
+ const updatedPerSubject = await mapWithConcurrency([...idsBySubject], KMS_POOL_CONCURRENCY, async ([triggeredById, ids]) => {
45
+ const encryptedError = await encryptFailureError(STALE_JOB_RUN_ERROR, triggeredById);
39
46
  const updated = await updateMany(db, jobRunsTable, {
40
47
  status: "failed",
41
48
  error: encryptedError,
@@ -43,12 +50,12 @@ export async function markStaleJobRunsFailed(db, timeoutHours) {
43
50
  modifiedAt: now,
44
51
  modifiedById: "system",
45
52
  },
46
- // Re-assert status: "running" here (not just id) — closes the race
47
- // window between the selectMany above and this write, where the run
48
- // could have completed/failed normally in between and must not be
49
- // clobbered back to "failed" with the stale-timeout message.
50
- { id: run.id, status: "running" });
51
- runsMarkedFailed += updated.length;
52
- }
53
+ // Re-assert status: "running" (not just id) — closes the race window
54
+ // between the selectMany above and this write, where a run could have
55
+ // completed/failed normally and must not be clobbered back to "failed".
56
+ { id: { in: ids }, status: "running" });
57
+ return updated.length;
58
+ });
59
+ const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
53
60
  return { runsMarkedFailed };
54
61
  }