@cosmicdrift/kumiko-samples 0.352.0 → 0.354.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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
  5. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  6. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  7. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  8. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  9. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  10. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  11. package/packages/bundled-features/src/audit/changes.json +6 -0
  12. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  13. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  18. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  19. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  21. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  23. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  24. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  25. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  26. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  27. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  30. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  31. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  32. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  33. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  34. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  35. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  36. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  37. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  39. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  40. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  41. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
  42. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  43. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  44. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  45. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  46. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  48. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  49. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  50. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  51. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  52. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  53. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  54. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  55. package/packages/bundled-features/src/delivery/changes.json +6 -0
  56. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  57. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  58. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  59. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  60. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  61. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  62. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  63. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  64. package/packages/bundled-features/src/files/changes.json +24 -0
  65. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  66. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  67. package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
  68. package/packages/bundled-features/src/folders/changes.json +6 -0
  69. package/packages/bundled-features/src/folders/feature.ts +2 -12
  70. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  71. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  72. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  73. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  74. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  75. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  76. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  77. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  78. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  79. package/packages/bundled-features/src/jobs/changes.json +30 -0
  80. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  81. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  82. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  83. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  84. package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
  85. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  86. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  87. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  88. package/packages/bundled-features/src/jobs/index.ts +2 -0
  89. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  90. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  91. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  92. package/packages/bundled-features/src/ledger/changes.json +23 -0
  93. package/packages/bundled-features/src/ledger/entity.ts +17 -5
  94. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  95. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  96. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  97. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  98. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  99. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  100. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  101. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  102. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  103. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  104. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  105. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  106. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  107. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  108. package/packages/bundled-features/src/seo/changes.json +5 -0
  109. package/packages/bundled-features/src/seo/feature.ts +23 -7
  110. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  111. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  112. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  113. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  114. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  115. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  116. package/packages/bundled-features/src/shared/index.ts +4 -2
  117. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  118. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  119. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  120. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  121. package/packages/bundled-features/src/tags/feature.ts +2 -12
  122. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  123. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  125. package/packages/bundled-features/src/tenant/changes.json +19 -0
  126. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  127. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  128. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  129. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  130. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  131. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  132. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  133. package/packages/bundled-features/src/tenant/index.ts +1 -0
  134. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  135. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  136. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  137. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  138. package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
  139. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  140. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  141. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  145. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
  146. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
  147. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
  148. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  149. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  150. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  151. package/packages/bundled-features/src/user/changes.json +15 -0
  152. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  153. package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
  154. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  155. package/packages/bundled-features/src/user/seeding.ts +34 -0
  156. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  157. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  158. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  159. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  160. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  161. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  162. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  163. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  164. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  165. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
  166. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
  167. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  168. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  169. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  170. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  171. package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
  172. package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
  173. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  174. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  175. package/samples/apps/ui-walkthrough/package.json +3 -0
  176. package/samples/apps/use-all-bundled/README.md +1 -2
  177. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  178. package/samples/apps/user-data-rights-demo/README.md +1 -3
  179. package/samples/recipes/legal-pages/README.md +3 -4
  180. package/samples/recipes/managed-pages/README.md +2 -2
  181. package/samples/recipes/public-share-token/README.md +4 -5
  182. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  183. package/samples/recipes/security-baseline/README.md +6 -0
  184. package/samples/recipes/seed-migration/README.md +0 -2
  185. package/samples/recipes/tenant-settings/README.md +1 -1
  186. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -1,6 +1,7 @@
1
1
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
+ import { hostRowIsGone } from "../../shared/index.js";
4
5
  import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
5
6
  import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
6
7
 
@@ -13,6 +14,12 @@ const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
13
14
  // see 658/1). Block server-side instead: this is the same "referential
14
15
  // integrity has no FK here" reasoning set-folder.write.ts already applies to
15
16
  // folderId on assignment-creation.
17
+ //
18
+ // Assignments whose host is gone (hard-deleted, soft-deleted or unregistered)
19
+ // are cleaned up here instead of blocking forever: clear-folder can't reach
20
+ // them (it requires a visible host). A host-delete hook would break restore,
21
+ // so the cleanup runs lazily at folder:delete.
22
+ const ASSIGNMENT_PAGE_SIZE = 200;
16
23
  export function createDeleteFolderHandler(
17
24
  access: AccessRule = DEFAULT_FOLDER_ACCESS,
18
25
  ): WriteHandlerDef {
@@ -21,19 +28,44 @@ export function createDeleteFolderHandler(
21
28
  schema: deleteFolderPayloadSchema,
22
29
  access,
23
30
  description:
24
- "Deletes a folder from the tenant catalog and refuses while any entity is still filed in it; use it once that folder's contents have been unfiled or moved elsewhere.",
31
+ "Deletes a folder from the tenant catalog and refuses while any live entity is still filed in it (assignments of deleted hosts are cleaned up); use it once that folder's contents have been unfiled or moved elsewhere.",
25
32
  agent: { risk: "high" },
26
33
  handler: async (event, ctx) => {
27
34
  const payload = event.payload as { id: string }; // @cast-boundary engine-payload
28
- const assigned = await folderAssignmentExecutor.list(
29
- { filter: { field: "folderId", op: "eq", value: payload.id }, limit: 1 },
30
- event.user,
31
- ctx.db,
32
- );
33
- if (assigned.rows.length > 0) {
34
- return writeFailure(
35
- new UnprocessableError("folder_has_assignments", { details: { folderId: payload.id } }),
35
+ const orphanAssignmentIds: string[] = [];
36
+ let cursor: string | undefined;
37
+ do {
38
+ const page = await folderAssignmentExecutor.list(
39
+ {
40
+ filter: { field: "folderId", op: "eq", value: payload.id },
41
+ limit: ASSIGNMENT_PAGE_SIZE,
42
+ cursor,
43
+ },
44
+ event.user,
45
+ ctx.db,
36
46
  );
47
+ for (const row of page.rows) {
48
+ const gone = await hostRowIsGone(
49
+ ctx.registry,
50
+ String(row["entityType"]),
51
+ String(row["entityId"]),
52
+ ctx.db,
53
+ );
54
+ if (!gone) {
55
+ return writeFailure(
56
+ new UnprocessableError("folder_has_assignments", {
57
+ details: { folderId: payload.id },
58
+ }),
59
+ );
60
+ }
61
+ orphanAssignmentIds.push(String(row["id"]));
62
+ }
63
+ cursor = page.nextCursor ?? undefined;
64
+ } while (cursor !== undefined);
65
+
66
+ for (const id of orphanAssignmentIds) {
67
+ const removed = await folderAssignmentExecutor.delete({ id }, event.user, ctx.db);
68
+ if (!removed.isSuccess) return removed;
37
69
  }
38
70
  return folderExecutor.delete({ id: payload.id }, event.user, ctx.db);
39
71
  },
@@ -1 +1,7 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "fix",
5
+ "title": "The stale-draft cleanup keeps sweeping when a whole batch was re-saved in the meantime"
6
+ }
7
+ ]
@@ -57,6 +57,7 @@ export async function selectStaleDraftsBatch(
57
57
  /** Re-check staleness after the select→release window so a freshly saved draft is not deleted. */
58
58
  export async function isDraftStillStale(
59
59
  db: DbConnection,
60
+ tenantId: TenantId,
60
61
  id: string,
61
62
  olderThanDays: number,
62
63
  ): Promise<boolean> {
@@ -64,9 +65,10 @@ export async function isDraftStillStale(
64
65
  db,
65
66
  `SELECT 1 FROM "read_form_drafts"
66
67
  WHERE "id" = $1
67
- AND COALESCE("modified_at", "inserted_at") < now() - ($2::int * interval '1 day')
68
+ AND "tenant_id" = $2
69
+ AND COALESCE("modified_at", "inserted_at") < now() - ($3::int * interval '1 day')
68
70
  LIMIT 1`,
69
- [id, olderThanDays],
71
+ [id, tenantId, olderThanDays],
70
72
  )) as readonly unknown[];
71
73
  return rows.length > 0;
72
74
  }
@@ -121,20 +121,24 @@ export function groupStaleDraftIdsByTenant(
121
121
  // resurrects, PII included. formDraftExecutor.delete() is the event-sourced
122
122
  // path; it needs a tenant-scoped db + a SessionUser, so rows are grouped by
123
123
  // tenant first (see groupStaleDraftIdsByTenant above).
124
- async function deleteStaleDraftsBatch(
124
+ export async function deleteStaleDraftsBatch(
125
125
  batch: readonly StaleDraftRow[],
126
126
  db: DbConnection,
127
127
  retentionDays: number,
128
128
  log: AppContext["log"],
129
- ): Promise<readonly StaleDraftRow[]> {
129
+ ): Promise<{ readonly deletedRows: readonly StaleDraftRow[]; readonly skippedCount: number }> {
130
130
  const deletedRows: StaleDraftRow[] = [];
131
+ let skippedCount = 0;
131
132
  const byId = new Map(batch.map((row) => [row.id, row]));
132
133
  for (const [tenantId, ids] of groupStaleDraftIdsByTenant(batch)) {
133
134
  const systemUser = createSystemUser(tenantId);
134
135
  const tenantDb = createTenantDb(db, tenantId, "system");
135
136
  for (const id of ids) {
136
137
  // Race: user may have saved between select and here — skip if no longer stale.
137
- if (!(await isDraftStillStale(db, id, retentionDays))) continue;
138
+ if (!(await isDraftStillStale(db, tenantId, id, retentionDays))) {
139
+ skippedCount++;
140
+ continue;
141
+ }
138
142
  const result = await formDraftExecutor.delete({ id }, systemUser, tenantDb);
139
143
  if (result.isSuccess) {
140
144
  const row = byId.get(id);
@@ -146,7 +150,7 @@ async function deleteStaleDraftsBatch(
146
150
  }
147
151
  }
148
152
  }
149
- return deletedRows;
153
+ return { deletedRows, skippedCount };
150
154
  }
151
155
 
152
156
  export async function cleanupDraftsJob(
@@ -189,12 +193,20 @@ export async function cleanupDraftsJob(
189
193
  // entirely for rows with no FileRefs in the blob, the common case.
190
194
  // Delete first (with staleness re-check), then release binaries from rows
191
195
  // that actually disappeared — never release a draft the user just saved.
192
- const deletedRows = await deleteStaleDraftsBatch(batch, db, retentionDays, ctx.log);
196
+ const { deletedRows, skippedCount } = await deleteStaleDraftsBatch(
197
+ batch,
198
+ db,
199
+ retentionDays,
200
+ ctx.log,
201
+ );
193
202
  for (const row of deletedRows) {
194
203
  await releaseRowFileRefs(row, db, fileProviderResolver, ctx.log);
195
204
  }
196
205
  deleted += deletedRows.length;
197
- if (deletedRows.length === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
206
+ // A skipped row is no longer stale, so the next select drops it — a fully
207
+ // skipped batch still makes progress. Only a batch with neither deletes nor
208
+ // skips (all failed) would reselect the same rows forever.
209
+ if (deletedRows.length + skippedCount === 0 || batch.length < DEFAULT_BATCH_SIZE) break;
198
210
  }
199
211
  ctx.log?.info?.(`[form-draft:cleanup] deleted=${deleted} retentionDays=${retentionDays}`);
200
212
  }
@@ -9,6 +9,7 @@ export {
9
9
  MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
10
10
  type MailConnectTarget,
11
11
  type MailHostGuardOptions,
12
+ mailAllowedPrivateHostsEnvSchema,
12
13
  readAllowedPrivateMailHostsFromEnv,
13
14
  resolveMailConnectTarget,
14
15
  } from "./mail-host-policy.js";
@@ -10,18 +10,30 @@
10
10
  // internal relay or a dev/test server (mailpit, greenmail) — deliberately
11
11
  // an operator env var (KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, read via
12
12
  // readAllowedPrivateMailHostsFromEnv), never a tenant-config key, so a
13
- // tenant can never grant themselves the bypass. Shared by both features
14
- // since a deploy commonly mounts both; declared once in mail-transport-
15
- // smtp's envSchema (see its feature.ts) to avoid the framework's env-var-
16
- // conflict check tripping when both features are mounted together.
13
+ // tenant can never grant themselves the bypass. Both features register the
14
+ // shared mailAllowedPrivateHostsEnvSchema below.
17
15
 
18
16
  import type { lookup } from "node:dns/promises";
19
17
  import { isIP } from "node:net";
20
18
  import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
19
+ import * as z from "zod";
21
20
  import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
22
21
 
23
22
  export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
24
23
 
24
+ // One schema instance, registered by every feature that reads the variable:
25
+ // composeEnvSchema accepts the same field instance from several features, so
26
+ // an app mounting only one of them still gets the variable validated and
27
+ // documented.
28
+ export const mailAllowedPrivateHostsEnvSchema = z.object({
29
+ [MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
30
+ .string()
31
+ .optional()
32
+ .describe(
33
+ "Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared by mail-transport-smtp and inbound-provider-imap — never a tenant-config value.",
34
+ ),
35
+ });
36
+
25
37
  export function readAllowedPrivateMailHostsFromEnv(
26
38
  env: Readonly<Record<string, string | undefined>> = process.env,
27
39
  ): readonly string[] {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "inbound-provider-imap declares the MAIL_ALLOWED_PRIVATE_HOSTS env schema",
6
+ "migration": "No action needed. An app that mounts both mail-transport-smtp and inbound-provider-imap now composes one shared field."
7
+ },
2
8
  {
3
9
  "version": "0.320.0",
4
10
  "type": "breaking",
@@ -23,6 +23,7 @@
23
23
  // (credential-document.ts) — kein Tenant-Config, weil mehrere Accounts
24
24
  // pro Tenant verschiedene Hosts haben.
25
25
 
26
+ import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
26
27
  import {
27
28
  INBOUND_MAIL_PROVIDER_EXTENSION,
28
29
  InboundAuthError,
@@ -282,5 +283,6 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
282
283
  });
283
284
  r.requires("inbound-mail-foundation");
284
285
  r.requires("secrets");
286
+ r.envSchema(mailAllowedPrivateHostsEnvSchema);
285
287
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
286
288
  });
@@ -39,10 +39,9 @@ const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed
39
39
 
40
40
  // Operator escape hatch for an internal relay or a dev/test IMAP server
41
41
  // (greenmail): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, the same operator env
42
- // var mail-transport-smtp declares (see its envSchema — composeEnvSchema
43
- // rejects two features declaring the same key, so this feature reads it
44
- // without redeclaring it), never a tenant-config value, so a tenant can
45
- // never grant themselves the private-host bypass.
42
+ // var mail-transport-smtp registers (both share one schema instance, see
43
+ // foundation-shared), never a tenant-config value, so a tenant can never
44
+ // grant themselves the private-host bypass.
46
45
  //
47
46
  // mailHostLookup is a test-only DNS seam — production leaves it undefined,
48
47
  // so resolveMailConnectTarget uses the real resolver. Module-global state:
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "jobs: store_tenant_job_failures holds at most one row per (tenant, job, subject)",
6
+ "migration": "store_tenant_job_failures now enforces one row per (tenant_id, job_name, subject) through two partial unique indexes; parallel final failures serialize on an advisory lock. kumiko schema generate does NOT produce the dedup, and the unique index would fail on existing duplicates, so hand-edit the generated migration to contain exactly: DELETE FROM \"store_tenant_job_failures\" a USING \"store_tenant_job_failures\" b WHERE a.\"tenant_id\" = b.\"tenant_id\" AND a.\"job_name\" = b.\"job_name\" AND a.\"subject\" IS NOT DISTINCT FROM b.\"subject\" AND (a.\"failed_at\", a.\"id\") < (b.\"failed_at\", b.\"id\"); CREATE UNIQUE INDEX IF NOT EXISTS \"store_tenant_job_failures_tenant_job_subject_uidx\" ON \"store_tenant_job_failures\" (\"tenant_id\", \"job_name\", \"subject\") WHERE subject IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS \"store_tenant_job_failures_tenant_job_null_subject_uidx\" ON \"store_tenant_job_failures\" (\"tenant_id\", \"job_name\") WHERE subject IS NULL; DROP INDEX IF EXISTS \"store_tenant_job_failures_tenant_job_subject_idx\"; The DELETE keeps the newest row per key (by failed_at, then id)."
7
+ },
8
+ {
9
+ "version": "0.354.0",
10
+ "type": "improvement",
11
+ "title": "Tenant-visible job run state: r.job({ tenantVisibleRun }) plus jobs:query:tenant-runs",
12
+ "detail": "A job that declares `tenantVisibleRun` keeps its queued, running, completed and failed state per tenant and subject in the new table `store_tenant_job_runs`, and the tenant reads it through `jobs:query:tenant-runs` (payload `{ jobName?, subject?, limit? }`, own tenant only). Each row is `{ jobName, subject, status, queuedAt, startedAt, finishedAt }`: every queued or running run plus the latest completed or failed one per job and subject, active runs first. No payload, error text or log ever reaches the tenant. A failed attempt that is retried shows as `queued` again. The stale-run sweep and `retention-cleanup` also cover the new table. `JobRunnerOptions` gains `onJobQueued` and `onJobDropped`.",
13
+ "migration": "New store table. Run `kumiko-schema generate <name>` and apply the migration with `kumiko-schema apply` - `store_tenant_job_runs` is created empty and stays empty until a job declares `tenantVisibleRun`. No change needed for apps that do not opt in."
14
+ },
15
+ {
16
+ "version": "0.354.0",
17
+ "type": "fix",
18
+ "title": "jobs:query:list and jobs:query:failures reject a fractional limit, and migration hints name `kumiko-schema generate`",
19
+ "detail": "Migration hints name the real `kumiko-schema generate` command, and the jobs list queries reject fractional `limit` values\nThe generated migration header and the recipe READMEs pointed at a non-existent `kumiko migrate generate`. `jobs:query:list` and `jobs:query:failures` now validate `limit` as an integer (at least 1), so `limit: 2.5` fails validation instead of reaching the database query."
20
+ },
21
+ {
22
+ "version": "0.354.0",
23
+ "type": "fix",
24
+ "title": "store_tenant_job_failures gets three indexes and jobs:query:failures lists one record per job and subject",
25
+ "migration": "Run `kumiko schema generate`; it emits in-place CREATE INDEX statements with no DROP and no rebuild."
26
+ },
27
+ {
28
+ "version": "0.354.0",
29
+ "type": "fix",
30
+ "title": "The job worker starts consuming only after the boot gates passed"
31
+ },
2
32
  {
3
33
  "version": "0.343.0",
4
34
  "type": "improvement",
@@ -14,6 +14,7 @@ export const JobQueries = {
14
14
  details: "jobs:query:details",
15
15
  catalog: "jobs:query:catalog",
16
16
  failures: "jobs:query:failures",
17
+ tenantRuns: "jobs:query:tenant-runs",
17
18
  } as const;
18
19
 
19
20
  // Error codes
@@ -14,6 +14,7 @@ import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
14
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
15
  import { jobRunLogsTable, jobRunsTable } from "../../job-run-table.js";
16
16
  import { tenantJobFailuresTable } from "../../tenant-job-failure-table.js";
17
+ import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
17
18
 
18
19
  const RETENTION_DELETE_BATCH_SIZE = 500;
19
20
 
@@ -21,6 +22,7 @@ export type JobRunRetentionResult = {
21
22
  readonly runsDeleted: number;
22
23
  readonly logsDeleted: number;
23
24
  readonly tenantFailuresDeleted: number;
25
+ readonly tenantRunsDeleted: number;
24
26
  };
25
27
 
26
28
  export async function deleteStaleJobRuns(
@@ -52,9 +54,19 @@ export async function deleteStaleJobRuns(
52
54
  { limit: RETENTION_DELETE_BATCH_SIZE },
53
55
  );
54
56
 
57
+ // Only finished rows: active ones are the stale sweep's business, and a
58
+ // still-running job older than the window must not vanish from the tenant.
59
+ const tenantRunsResult = await deleteManyBatched(
60
+ db,
61
+ tenantJobRunsTable,
62
+ { status: { in: ["completed", "failed"] }, updatedAt: { lt: cutoff } },
63
+ { limit: RETENTION_DELETE_BATCH_SIZE },
64
+ );
65
+
55
66
  return {
56
67
  runsDeleted: runsResult.deleted,
57
68
  logsDeleted: logsResult.deleted,
58
69
  tenantFailuresDeleted: tenantFailuresResult.deleted,
70
+ tenantRunsDeleted: tenantRunsResult.deleted,
59
71
  };
60
72
  }
@@ -12,19 +12,23 @@
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
 
15
- import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { deleteManyBatched, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
16
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
17
17
  import { mapWithConcurrency } from "../../../shared/index.js";
18
18
  import { encryptFailureError } from "../../job-run-logger.js";
19
19
  import { jobRunsTable } from "../../job-run-table.js";
20
+ import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
20
21
 
21
22
  const KMS_POOL_CONCURRENCY = 4;
23
+ const STALE_DELETE_BATCH_SIZE = 500;
22
24
 
23
25
  export const STALE_JOB_RUN_ERROR =
24
26
  "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
25
27
 
26
28
  export type StaleRunSweepResult = {
27
29
  readonly runsMarkedFailed: number;
30
+ readonly tenantRunsMarkedFailed: number;
31
+ readonly tenantQueuedDropped: number;
28
32
  };
29
33
 
30
34
  export async function markStaleJobRunsFailed(
@@ -80,5 +84,25 @@ export async function markStaleJobRunsFailed(
80
84
  );
81
85
  const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
82
86
 
83
- return { runsMarkedFailed };
87
+ // Tenant-visible run state follows the same clock: a running row whose
88
+ // worker died is a failure, a queued row that never started (job lost from
89
+ // Redis, enqueue hook without a run) would otherwise show "waiting" forever.
90
+ const tenantRunsFailed = await updateMany(
91
+ db,
92
+ tenantJobRunsTable,
93
+ { status: "failed", finishedAt: now, updatedAt: now },
94
+ { status: "running", startedAt: { lt: cutoff } },
95
+ );
96
+ const tenantQueuedStale = await deleteManyBatched(
97
+ db,
98
+ tenantJobRunsTable,
99
+ { status: "queued", updatedAt: { lt: cutoff } },
100
+ { limit: STALE_DELETE_BATCH_SIZE },
101
+ );
102
+
103
+ return {
104
+ runsMarkedFailed,
105
+ tenantRunsMarkedFailed: tenantRunsFailed.length,
106
+ tenantQueuedDropped: tenantQueuedStale.deleted,
107
+ };
84
108
  }
@@ -27,10 +27,12 @@ import {
27
27
  DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS,
28
28
  } from "./handlers/stale-run-sweep.job.js";
29
29
  import { tenantFailuresQuery } from "./handlers/tenant-failures.query.js";
30
+ import { tenantRunsQuery } from "./handlers/tenant-runs.query.js";
30
31
  import { triggerWrite } from "./handlers/trigger.write.js";
31
32
  import { JOBS_I18N } from "./i18n.js";
32
33
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.js";
33
34
  import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table.js";
35
+ import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
34
36
 
35
37
  export type JobsFeatureOptions = {
36
38
  // How long a job run (and its logs) stays in store_job_runs/
@@ -49,7 +51,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
49
51
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
50
52
  return defineFeature("jobs", (r) => {
51
53
  r.describe(
52
- "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079).",
54
+ "Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops its stuck `queued` rows.",
53
55
  );
54
56
  r.uiHints({
55
57
  displayLabel: "Jobs · Audit & Operator UI",
@@ -71,6 +73,9 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
71
73
  r.storeTable(tenantJobFailuresTableMeta, {
72
74
  reason: "direct_write.tenant_job_failures",
73
75
  });
76
+ r.storeTable(tenantJobRunsTableMeta, {
77
+ reason: "direct_write.tenant_job_runs",
78
+ });
74
79
 
75
80
  // Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
76
81
  r.job(
@@ -123,6 +128,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
123
128
  detail: r.queryHandler(detailQuery),
124
129
  catalog: r.queryHandler(catalogQuery),
125
130
  failures: r.queryHandler(tenantFailuresQuery),
131
+ tenantRuns: r.queryHandler(tenantRunsQuery),
126
132
  };
127
133
 
128
134
  const systemAdminAccess = { roles: ["SystemAdmin"] as const };
@@ -1,11 +1,13 @@
1
1
  import { countWhere, selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
2
3
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import * as z from "zod";
5
6
  import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
6
7
  import { jobRunsTable } from "../job-run-table.js";
7
8
 
8
9
  const KMS_POOL_CONCURRENCY = 4;
10
+ const DEFAULT_PAGE_SIZE = 50;
9
11
 
10
12
  const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
11
13
 
@@ -23,6 +25,31 @@ async function decryptRunRow<T extends Record<string, unknown>>(row: T): Promise
23
25
  return result;
24
26
  }
25
27
 
28
+ type JobRunStatus = z.infer<typeof jobRunStatusSchema>;
29
+
30
+ function buildListWhere(payload: {
31
+ readonly jobName?: string | undefined;
32
+ readonly status?: JobRunStatus | undefined;
33
+ readonly filters?: readonly { readonly value: readonly JobRunStatus[] }[] | undefined;
34
+ }): WhereObject {
35
+ const where: WhereObject = {};
36
+ if (payload.jobName) where["jobName"] = payload.jobName;
37
+ const statuses = payload.filters?.[0]?.value ?? (payload.status ? [payload.status] : []);
38
+ if (statuses.length === 1) where["status"] = statuses[0];
39
+ else if (statuses.length > 1) where["status"] = { in: statuses };
40
+ return where;
41
+ }
42
+
43
+ function parseOffsetCursor(cursor: string | undefined): number {
44
+ const offset = cursor ? Number(decodeCursor(cursor)) : 0;
45
+ if (!Number.isInteger(offset) || offset < 0) {
46
+ throw new ValidationError({
47
+ fields: [{ path: "cursor", code: "invalid_cursor", i18nKey: "jobs.errors.invalidCursor" }],
48
+ });
49
+ }
50
+ return offset;
51
+ }
52
+
26
53
  export const listQuery = defineQueryHandler({
27
54
  name: "list",
28
55
  description:
@@ -41,7 +68,8 @@ export const listQuery = defineQueryHandler({
41
68
  .optional(),
42
69
  sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
43
70
  sortDirection: z.enum(["asc", "desc"]).optional(),
44
- limit: z.number().optional(),
71
+ limit: z.number().int().min(1).optional(),
72
+ cursor: z.string().optional(),
45
73
  totalCount: z.boolean().optional(),
46
74
  }),
47
75
  access: { roles: ["SystemAdmin"] },
@@ -54,17 +82,22 @@ export const listQuery = defineQueryHandler({
54
82
  const db = ctx.systemDb.unsafeRaw(
55
83
  "cross-tenant job monitoring: SystemAdmin lists job runs of every tenant",
56
84
  );
57
- const where: WhereObject = {};
58
- if (query.payload.jobName) where["jobName"] = query.payload.jobName;
59
- const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
60
- const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
61
- if (statuses.length === 1) where["status"] = statuses[0];
62
- else if (statuses.length > 1) where["status"] = { in: statuses };
85
+ const where = buildListWhere(query.payload);
63
86
  const sortColumn = query.payload.sort ?? "startedAt";
64
- const rows = await selectMany(db, jobRunsTable, where, {
65
- orderBy: { col: sortColumn, direction: query.payload.sortDirection ?? "desc" },
66
- limit: query.payload.limit ?? 50,
87
+ const limit = query.payload.limit ?? DEFAULT_PAGE_SIZE;
88
+ const offset = parseOffsetCursor(query.payload.cursor);
89
+ const direction = query.payload.sortDirection ?? "desc";
90
+ // selectMany has no OFFSET: read through the end of the page plus one probe
91
+ // row, then slice. `id` as tiebreaker keeps pages stable for non-unique sorts.
92
+ const fetched = await selectMany(db, jobRunsTable, where, {
93
+ orderBy: [
94
+ { col: sortColumn, direction },
95
+ { col: "id", direction },
96
+ ],
97
+ limit: offset + limit + 1,
67
98
  });
99
+ const hasMore = fetched.length > offset + limit;
100
+ const rows = fetched.slice(offset, offset + limit);
68
101
  // countWhere reruns the SAME `where` without the limit — `rows.length` is
69
102
  // capped at the page size and would silently undercount the total.
70
103
  const total =
@@ -72,7 +105,7 @@ export const listQuery = defineQueryHandler({
72
105
  // payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
73
106
  return {
74
107
  rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow),
75
- nextCursor: null,
108
+ nextCursor: hasMore ? encodeCursor(String(offset + limit)) : null,
76
109
  ...(total !== undefined && { total }),
77
110
  };
78
111
  },
@@ -37,7 +37,7 @@ export const tenantFailuresQuery = defineQueryHandler({
37
37
  schema: z.object({
38
38
  jobName: z.string().optional(),
39
39
  subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
40
- limit: z.number().min(1).max(200).optional(),
40
+ limit: z.number().int().min(1).max(200).optional(),
41
41
  }),
42
42
  // Every membership rank: a failure record carries a job name and a
43
43
  // translation key, nothing a team member of the tenant may not see.
@@ -0,0 +1,94 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
5
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
6
+ import * as z from "zod";
7
+ import { type TenantJobRunStatus, tenantJobRunsTable } from "../tenant-job-run-table.js";
8
+
9
+ type TenantJobRunRow = {
10
+ readonly tenantId: string;
11
+ readonly jobName: string;
12
+ readonly subject: string | null;
13
+ readonly status: TenantJobRunStatus;
14
+ readonly queuedAt: Temporal.Instant | null;
15
+ readonly startedAt: Temporal.Instant | null;
16
+ readonly finishedAt: Temporal.Instant | null;
17
+ readonly updatedAt: Temporal.Instant;
18
+ };
19
+
20
+ const DEFAULT_LIMIT = 50;
21
+
22
+ function isSubjectEntry(value: unknown): value is [string, unknown] {
23
+ return Array.isArray(value) && typeof value[0] === "string";
24
+ }
25
+
26
+ function parseSubject(stored: string | null): Record<string, unknown> | null {
27
+ if (stored === null) return null;
28
+ // A corrupt subject must not fail the whole list; parseJsonSafe only
29
+ // survives a SyntaxError, so the shape needs its own check.
30
+ const parsed = parseJsonSafe<unknown>(stored, null);
31
+ if (!Array.isArray(parsed)) return null;
32
+ return Object.fromEntries(parsed.filter(isSubjectEntry));
33
+ }
34
+
35
+ export const tenantRunsQuery = defineQueryHandler({
36
+ name: "tenant-runs",
37
+ description:
38
+ "Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
39
+ schema: z.object({
40
+ jobName: z.string().optional(),
41
+ subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
42
+ limit: z.number().int().min(1).max(200).optional(),
43
+ }),
44
+ // Every membership rank: a run state carries a job name, a status and
45
+ // times, nothing a team member of the tenant may not see.
46
+ access: { roles: access.roles("User", "Editor", ...access.admin) },
47
+ handler: async (query, ctx) => {
48
+ if (!ctx.systemDb) {
49
+ throw new InternalError({
50
+ message:
51
+ "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
52
+ });
53
+ }
54
+ // assertTenantMatch returns the same unfiltered system-mode db — the
55
+ // explicit tenantId below is the filter, assertRowsTenant the second net.
56
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
57
+ const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
58
+ if (query.payload.jobName) where["jobName"] = query.payload.jobName;
59
+ if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
60
+ const limit = query.payload.limit ?? DEFAULT_LIMIT;
61
+ const newestFirst = { col: "updatedAt", direction: "desc" } as const;
62
+ const activeRows = await selectMany<TenantJobRunRow>(
63
+ db,
64
+ tenantJobRunsTable,
65
+ { ...where, status: { in: ["queued", "running"] } },
66
+ { orderBy: newestFirst, limit },
67
+ );
68
+ const finishedRows = await selectMany<TenantJobRunRow>(
69
+ db,
70
+ tenantJobRunsTable,
71
+ { ...where, status: { in: ["completed", "failed"] } },
72
+ { orderBy: newestFirst, limit },
73
+ );
74
+ const seenFinishedKeys = new Set<string>();
75
+ const finishedNewestPerKey = finishedRows.filter((row) => {
76
+ const key = JSON.stringify([row.jobName, row.subject]);
77
+ if (seenFinishedKeys.has(key)) return false;
78
+ seenFinishedKeys.add(key);
79
+ return true;
80
+ });
81
+ const activeFirst = [...activeRows, ...finishedNewestPerKey];
82
+ return {
83
+ rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
84
+ jobName: row.jobName,
85
+ subject: parseSubject(row.subject),
86
+ status: row.status,
87
+ queuedAt: row.queuedAt,
88
+ startedAt: row.startedAt,
89
+ finishedAt: row.finishedAt,
90
+ })),
91
+ nextCursor: null,
92
+ };
93
+ },
94
+ });
@@ -36,6 +36,7 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
36
36
  "jobs.detail.logs": { en: "Logs" },
37
37
  "jobs.detail.retry": { en: "Retry" },
38
38
  "jobs.trigger.title": { en: "Run a job" },
39
+ "jobs.errors.invalidCursor": { en: "The pagination cursor is invalid." },
39
40
  "jobs.trigger.submit": { en: "Run" },
40
41
  "jobs.errors.unknownJob": { en: "Unknown job." },
41
42
  "jobs.errors.notManual": {