@cosmicdrift/kumiko-bundled-features 0.353.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 (181) hide show
  1. package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
  2. package/dist/agent-tools/agent-doc-lint.js +61 -1
  3. package/dist/agent-tools/agent-manifest.d.ts +1 -0
  4. package/dist/agent-tools/agent-manifest.js +9 -3
  5. package/dist/agent-tools/feature.js +7 -2
  6. package/dist/agent-tools/index.d.ts +1 -1
  7. package/dist/agent-tools/index.js +1 -1
  8. package/dist/agent-tools/tool-dispatch.js +1 -1
  9. package/dist/auth-email-password/handlers/change-password.write.js +1 -0
  10. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  11. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
  12. package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
  13. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
  14. package/dist/auth-email-password/handlers/login.write.js +1 -0
  15. package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
  16. package/dist/auth-email-password/handlers/signup-confirm.write.js +4 -1
  17. package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
  18. package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
  19. package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
  20. package/dist/auth-email-password/index.d.ts +2 -0
  21. package/dist/auth-email-password/index.js +2 -0
  22. package/dist/auth-email-password/seeding.d.ts +2 -0
  23. package/dist/auth-email-password/seeding.js +30 -8
  24. package/dist/auth-email-password/signup-activation.d.ts +24 -0
  25. package/dist/auth-email-password/signup-activation.js +41 -0
  26. package/dist/auth-email-password/signup-token-store.d.ts +5 -0
  27. package/dist/auth-email-password/signup-token-store.js +8 -3
  28. package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
  29. package/dist/auth-email-password/web/auth-gate.js +5 -5
  30. package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
  31. package/dist/auth-email-password/web/auth-redirect.js +14 -0
  32. package/dist/auth-email-password/web/login-screen.js +2 -2
  33. package/dist/auth-email-password/web/user-menu.js +1 -1
  34. package/dist/auth-mfa/feature.js +5 -0
  35. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
  36. package/dist/auth-mfa/handlers/verify.write.js +1 -0
  37. package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
  38. package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
  39. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
  40. package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
  41. package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
  42. package/dist/billing-foundation/billing-info-query.d.ts +5 -2
  43. package/dist/channel-in-app/in-app-channel.js +1 -1
  44. package/dist/channel-in-app/tables.js +4 -2
  45. package/dist/compliance-profiles/schema/profile-selection.d.ts +2 -0
  46. package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
  47. package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
  48. package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
  49. package/dist/delivery/db/queries/address-opt-outs.js +12 -7
  50. package/dist/delivery/delivery-service.js +60 -12
  51. package/dist/delivery/unsubscribe.js +66 -14
  52. package/dist/feature-toggles/feature.js +2 -3
  53. package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
  54. package/dist/feature-toggles/toggle-runtime.js +30 -6
  55. package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
  56. package/dist/feature-toggles/toggle-sync-signal.js +29 -10
  57. package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
  58. package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
  59. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
  60. package/dist/files-tenant-data/hooks.js +58 -22
  61. package/dist/folders/handlers/delete-folder.write.js +31 -4
  62. package/dist/foundation-shared/index.d.ts +1 -1
  63. package/dist/foundation-shared/index.js +1 -1
  64. package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
  65. package/dist/foundation-shared/mail-host-policy.js +13 -4
  66. package/dist/inbound-provider-imap/feature.js +2 -0
  67. package/dist/inbound-provider-imap/imap-client.js +3 -4
  68. package/dist/jobs/constants.d.ts +1 -0
  69. package/dist/jobs/constants.js +1 -0
  70. package/dist/jobs/db/queries/retention.d.ts +1 -0
  71. package/dist/jobs/db/queries/retention.js +5 -0
  72. package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
  73. package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
  74. package/dist/jobs/feature.js +7 -1
  75. package/dist/jobs/handlers/list.query.js +1 -1
  76. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  77. package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
  78. package/dist/jobs/handlers/tenant-runs.query.js +72 -0
  79. package/dist/jobs/index.d.ts +2 -0
  80. package/dist/jobs/index.js +1 -0
  81. package/dist/jobs/job-run-logger.d.ts +2 -2
  82. package/dist/jobs/job-run-logger.js +98 -9
  83. package/dist/jobs/tenant-job-failure-table.js +15 -2
  84. package/dist/jobs/tenant-job-run-table.d.ts +4 -0
  85. package/dist/jobs/tenant-job-run-table.js +34 -0
  86. package/dist/ledger/entity.d.ts +3 -0
  87. package/dist/ledger/entity.js +13 -5
  88. package/dist/ledger/feature.js +3 -0
  89. package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
  90. package/dist/ledger/handlers/create-transaction.write.js +1 -0
  91. package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
  92. package/dist/ledger/schedule-subject-pair.d.ts +2 -0
  93. package/dist/ledger/schedule-subject-pair.js +24 -0
  94. package/dist/mail-transport-smtp/feature.d.ts +3 -4
  95. package/dist/mail-transport-smtp/feature.js +4 -13
  96. package/dist/notes-history/entity.js +4 -0
  97. package/dist/personal-access-tokens/handlers/create.write.js +1 -0
  98. package/dist/sessions/feature.d.ts +1 -0
  99. package/dist/sessions/feature.js +9 -8
  100. package/dist/sessions/handlers/detail.query.d.ts +1 -1
  101. package/dist/sessions/handlers/detail.query.js +2 -2
  102. package/dist/sessions/handlers/list.query.d.ts +1 -1
  103. package/dist/sessions/handlers/list.query.js +5 -3
  104. package/dist/sessions/screens.d.ts +2 -2
  105. package/dist/sessions/screens.js +6 -7
  106. package/dist/shared/encrypt-for-direct-write.js +10 -2
  107. package/dist/shared/index.d.ts +2 -2
  108. package/dist/shared/index.js +2 -2
  109. package/dist/shared/parent-visibility.d.ts +2 -1
  110. package/dist/shared/parent-visibility.js +21 -0
  111. package/dist/shared/row-bound-grant.js +16 -2
  112. package/dist/shared/session-locale-field.d.ts +1 -0
  113. package/dist/shared/session-locale-field.js +6 -0
  114. package/dist/template-resolver/api.d.ts +2 -2
  115. package/dist/template-resolver/handlers/list.query.d.ts +2 -0
  116. package/dist/template-resolver/handlers/list.query.js +2 -2
  117. package/dist/template-resolver/table.d.ts +8 -0
  118. package/dist/template-resolver/user-content-table.d.ts +4 -0
  119. package/dist/tenant/feature.js +7 -1
  120. package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
  121. package/dist/tenant/handlers/update-member-roles.write.js +1 -0
  122. package/dist/tenant/invitation-table.d.ts +40 -0
  123. package/dist/tenant/invitation-table.js +6 -1
  124. package/dist/tenant/invited-membership.d.ts +8 -6
  125. package/dist/tenant/invited-membership.js +28 -7
  126. package/dist/tenant/schema/tenant.d.ts +2 -0
  127. package/dist/tenant-handover/transfer-graph.d.ts +2 -21
  128. package/dist/tenant-handover/transfer-graph.js +8 -75
  129. package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
  130. package/dist/tenant-lifecycle/boot-checks.js +13 -0
  131. package/dist/tenant-lifecycle/constants.d.ts +3 -0
  132. package/dist/tenant-lifecycle/constants.js +5 -0
  133. package/dist/tenant-lifecycle/events.d.ts +5 -0
  134. package/dist/tenant-lifecycle/events.js +5 -0
  135. package/dist/tenant-lifecycle/feature.js +5 -3
  136. package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +7 -5
  137. package/dist/tenant-lifecycle/run-tenant-destroy.js +57 -17
  138. package/dist/tenant-lifecycle/stages.d.ts +6 -1
  139. package/dist/tenant-lifecycle/stages.js +26 -3
  140. package/dist/tenant-lifecycle/testing.d.ts +8 -0
  141. package/dist/tenant-lifecycle/testing.js +42 -0
  142. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
  143. package/dist/user/handlers/detail.query.js +2 -0
  144. package/dist/user/handlers/list.query.js +4 -0
  145. package/dist/user/handlers/update.write.js +1 -0
  146. package/dist/user/schema/user.d.ts +2 -0
  147. package/dist/user/seeding.d.ts +1 -0
  148. package/dist/user/seeding.js +20 -0
  149. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
  150. package/dist/user-data-rights/index.d.ts +1 -0
  151. package/dist/user-data-rights/index.js +1 -0
  152. package/dist/user-data-rights/schema/download-attempt.js +17 -9
  153. package/dist/user-data-rights/schema/export-job.d.ts +2 -0
  154. package/dist/user-profile/handlers/change-email.write.js +8 -2
  155. package/dist/waitlist/entity.d.ts +2 -0
  156. package/dist/workflow-runner/event-trigger.d.ts +1 -0
  157. package/dist/workflow-runner/event-trigger.js +60 -15
  158. package/dist/workflow-runner/handlers/resume-run.write.js +5 -2
  159. package/dist/workflow-runner/runner.d.ts +2 -1
  160. package/dist/workflow-runner/runner.js +5 -1
  161. package/package.json +35 -10
  162. package/src/auth-email-password/changes.json +24 -0
  163. package/src/auth-mfa/changes.json +7 -0
  164. package/src/billing-foundation/changes.json +6 -0
  165. package/src/channel-in-app/changes.json +8 -1
  166. package/src/crypto-shredding/changes.json +12 -0
  167. package/src/delivery/changes.json +6 -0
  168. package/src/files/changes.json +24 -0
  169. package/src/files-tenant-data/changes.json +5 -0
  170. package/src/folders/changes.json +6 -0
  171. package/src/inbound-provider-imap/changes.json +6 -0
  172. package/src/jobs/changes.json +30 -0
  173. package/src/ledger/changes.json +23 -0
  174. package/src/notes-history/changes.json +13 -0
  175. package/src/template-resolver/changes.json +5 -0
  176. package/src/tenant/changes.json +12 -0
  177. package/src/tenant-handover/changes.json +6 -0
  178. package/src/tenant-lifecycle/changes.json +22 -0
  179. package/src/tier-engine/changes.json +5 -0
  180. package/src/user/changes.json +15 -0
  181. package/src/user-data-rights/changes.json +6 -0
@@ -1,6 +1,10 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { computeEffectiveFeatures, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { createDefaultCacheSyncBus, } from "@cosmicdrift/kumiko-framework/redis";
3
5
  import { globalFeatureStateTable } from "./global-feature-state-table.js";
6
+ import { createCacheSyncToggleSignal } from "./toggle-sync-signal.js";
7
+ const log = createFallbackLogger("kumiko:feature-toggles");
4
8
  // Holds the current global-override snapshot in memory and exposes a
5
9
  // synchronous reader — the dispatcher's feature-gate calls it on every
6
10
  // handler invocation, so this must not do I/O on the hot path. The
@@ -16,14 +20,16 @@ export class GlobalFeatureToggleRuntime {
16
20
  db;
17
21
  registry;
18
22
  syncSignal;
23
+ ownedBus;
19
24
  snapshot = new Map();
20
25
  // Flips applied while a snapshot load is in flight. The load's SELECT may have
21
26
  // started before the flip committed, so its rows can be older than these.
22
27
  appliedDuringLoad;
23
- constructor(db, registry, syncSignal) {
28
+ constructor(db, registry, syncSignal, ownedBus) {
24
29
  this.db = db;
25
30
  this.registry = registry;
26
31
  this.syncSignal = syncSignal;
32
+ this.ownedBus = ownedBus;
27
33
  // Every process, including the one that calls broadcastToggle, learns
28
34
  // of a flip through this same subscription — mirrors the SSE broker's
29
35
  // publish-then-echo-to-self pattern instead of a separate local-apply
@@ -31,6 +37,15 @@ export class GlobalFeatureToggleRuntime {
31
37
  this.syncSignal?.onMessage((featureName, enabled) => {
32
38
  this.apply(featureName, enabled);
33
39
  });
40
+ this.syncSignal?.onResync?.(() => {
41
+ this.refresh().catch((err) => {
42
+ log.error("snapshot reload after sync reconnect failed", { err });
43
+ });
44
+ });
45
+ }
46
+ // Only the runtime built by createFeatureToggleRuntime without an explicit signal owns one.
47
+ async close() {
48
+ await this.ownedBus?.close();
34
49
  }
35
50
  async initialize() {
36
51
  const pendingFlips = new Map();
@@ -99,11 +114,20 @@ export class GlobalFeatureToggleRuntime {
99
114
  return computeEffectiveFeatures(this.registry, reader);
100
115
  };
101
116
  }
102
- // Factory for app-boot wiring: instantiate, initialize, return both the
103
- // runtime (for the set-handler to refresh) and the callback (for
104
- // createDispatcher's effectiveFeatures option).
117
+ // Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
118
+ // set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
119
+ // syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
120
+ // REDIS_URL is set, process-local otherwise. close() releases a bus it created.
105
121
  export async function createFeatureToggleRuntime(db, registry, syncSignal) {
106
- const runtime = new GlobalFeatureToggleRuntime(db, registry, syncSignal);
107
- await runtime.initialize();
122
+ const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
123
+ const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
124
+ const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
125
+ try {
126
+ await runtime.initialize();
127
+ }
128
+ catch (err) {
129
+ await runtime.close();
130
+ throw err;
131
+ }
108
132
  return runtime;
109
133
  }
@@ -1,6 +1,7 @@
1
- import { type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
1
+ import { type CacheSyncBus, type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
2
2
  import type { ToggleSyncSignal } from "./toggle-runtime.js";
3
3
  export type RedisToggleSyncSignal = ToggleSyncSignal & {
4
4
  close(): Promise<void>;
5
5
  };
6
- export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions): RedisToggleSyncSignal;
6
+ export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions, channelPrefix?: string): RedisToggleSyncSignal;
7
+ export declare function createCacheSyncToggleSignal(bus: CacheSyncBus): ToggleSyncSignal;
@@ -1,7 +1,7 @@
1
- import { createRedisPubSubSignal, } from "@cosmicdrift/kumiko-framework/redis";
1
+ import { CACHE_SYNC_TOPICS, createRedisPubSubSignal, redisChannelPrefixFromEnv, } 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
- const TOGGLE_SYNC_CHANNEL = "kumiko:feature-toggles:cache-sync";
4
+ const TOGGLE_SYNC_CHANNEL_NAME = "kumiko:feature-toggles:cache-sync";
5
5
  function isTogglePayload(value) {
6
6
  return (typeof value === "object" &&
7
7
  value !== null &&
@@ -11,26 +11,25 @@ function isTogglePayload(value) {
11
11
  // fw#2625: gives toggle-cache-sync the cross-replica transport its shared
12
12
  // dispatcher cursor needs — without this, only the one process that won
13
13
  // the shared cursor's toggle-set event would ever learn about a flip.
14
- // Same REDIS_URL-gated assumption as the SSE broker: a deployment with
15
- // replicas > 1 is expected to set REDIS_URL, so app-boot code should build
16
- // this only when REDIS_URL is present and pass it into
17
- // createFeatureToggleRuntime.
18
- export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
14
+ // Standalone Redis transport for callers that want their own connections;
15
+ // createFeatureToggleRuntime builds a cache-sync-bus signal by default.
16
+ export function createRedisToggleSyncSignal(redisUrl, clientOptions, channelPrefix = redisChannelPrefixFromEnv()) {
17
+ const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
19
18
  const signal = createRedisPubSubSignal({
20
19
  redisUrl,
21
- channelPattern: TOGGLE_SYNC_CHANNEL,
20
+ channelPattern: toggleSyncChannel,
22
21
  label: "feature-toggles",
23
22
  ...(clientOptions ? { clientOptions } : {}),
24
23
  });
25
24
  return {
26
25
  publish(featureName, enabled) {
27
- signal.publish(TOGGLE_SYNC_CHANNEL, { featureName, enabled });
26
+ signal.publish(toggleSyncChannel, { featureName, enabled });
28
27
  },
29
28
  onMessage(listener) {
30
29
  signal.onMessage((_channel, payload) => {
31
30
  if (!isTogglePayload(payload)) {
32
31
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.log in a raw PubSubSignal handler.
33
- console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${TOGGLE_SYNC_CHANNEL}"`);
32
+ console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${toggleSyncChannel}"`);
34
33
  // skip: malformed message already logged above, nothing to apply
35
34
  return;
36
35
  }
@@ -40,3 +39,23 @@ export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
40
39
  close: signal.close,
41
40
  };
42
41
  }
42
+ // Reuses the framework cache-sync bus (local without REDIS_URL, Redis-backed with it) so
43
+ // toggles need no Pub/Sub wiring of their own. The bus applies a publish locally first.
44
+ export function createCacheSyncToggleSignal(bus) {
45
+ return {
46
+ publish(featureName, enabled) {
47
+ bus.publish(CACHE_SYNC_TOPICS.featureToggle, { featureName, enabled });
48
+ },
49
+ onMessage(listener) {
50
+ bus.subscribe(CACHE_SYNC_TOPICS.featureToggle, (payload) => {
51
+ // skip: foreign or malformed payload on the topic
52
+ if (!isTogglePayload(payload))
53
+ return;
54
+ listener(payload.featureName, payload.enabled);
55
+ });
56
+ },
57
+ onResync(listener) {
58
+ bus.onResync(listener);
59
+ },
60
+ };
61
+ }
@@ -1,6 +1,7 @@
1
1
  import { EXT_DERIVATIVE_PUBLIC_PREDICATE, type HandlerContext, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  export type DerivativePublicPredicateArgs = {
4
+ readonly fileRefId: string;
4
5
  readonly entityId: string;
5
6
  readonly tenantId: TenantId;
6
7
  readonly fieldName: string;
@@ -78,7 +78,13 @@ export const publicVariantQuery = defineQueryHandler({
78
78
  if (!isDerivativePublicPredicatePlugin(usage.options)) {
79
79
  throw new Error(`file-derivatives: "${usage.entityName}" registered ${EXT_DERIVATIVE_PUBLIC_PREDICATE} without an isPublic(args, ctx) — extension options must be a DerivativePublicPredicatePlugin.`);
80
80
  }
81
- const isPublic = await usage.options.isPublic({ entityId, tenantId: ctx.user.tenantId, fieldName, variant: query.payload.variant }, ctx);
81
+ const isPublic = await usage.options.isPublic({
82
+ fileRefId: query.payload.fileRefId,
83
+ entityId,
84
+ tenantId: ctx.user.tenantId,
85
+ fieldName,
86
+ variant: query.payload.variant,
87
+ }, ctx);
82
88
  if (!isPublic)
83
89
  return null;
84
90
  // ctx.files/ctx.derivatives are optional on HandlerContext (only wired
@@ -99,6 +99,11 @@ export async function sweepOrphanedDerivativesJob(rawPayload, ctx, db) {
99
99
  const owners = await selectMany(db, fileRefsTable, { tenantId: candidate.tenantId, storageKey: candidate.originalKey }, { limit: 1 });
100
100
  if (owners.length > 0)
101
101
  return "skipped";
102
+ // Binaries written through ctx.files.ref(key) (and their derive()d
103
+ // variants) never get a fileRef row; an original still sitting in storage
104
+ // means the derivative is not an orphan.
105
+ if (await candidate.provider.exists(candidate.originalKey))
106
+ return "skipped";
102
107
  if (dryRun) {
103
108
  dryRunWouldDelete++;
104
109
  return "skipped";
@@ -49,11 +49,13 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
49
49
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
50
50
  import { createSystemUser, parseTenantId, } from "@cosmicdrift/kumiko-framework/engine";
51
51
  import { assertSafeStorageKey, fileRefEntity, fileRefsTable, storageKeyStemPrefix, tenantStoragePrefixes, } from "@cosmicdrift/kumiko-framework/files";
52
+ import { runChunkedMigration } from "../shared/index.js";
52
53
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
53
54
  // Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
54
55
  // — bounds each query instead of one `storage_key = ANY(...)` per listed key,
55
56
  // or a single unbounded array for a tenant with a huge prefix.
56
57
  const SURVIVOR_CHECK_BATCH_SIZE = 500;
58
+ const DESTROY_CHUNK_SIZE = 500;
57
59
  function chunk(items, size) {
58
60
  const chunks = [];
59
61
  for (let i = 0; i < items.length; i += size)
@@ -63,25 +65,43 @@ function chunk(items, size) {
63
65
  function isUnderOwnPrefix(storageKey, prefixes) {
64
66
  return prefixes.some((prefix) => storageKey.startsWith(prefix));
65
67
  }
68
+ function toDestroyHookResult(result, failureMessage) {
69
+ // A failure on the last item ends the run as "done" (nothing left to batch),
70
+ // so the failed count, not the stop reason, decides fail-closed.
71
+ if (result.failed > 0) {
72
+ throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
73
+ }
74
+ return { done: result.stoppedReason === "done", processed: result.migrated };
75
+ }
66
76
  export const fileRefTenantDestroyHook = async (ctx) => {
67
- const rows = await ctx.db.selectMany(fileRefsTable, {
68
- tenantId: ctx.tenantId,
69
- });
70
77
  const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
71
78
  const user = createSystemUser(ctx.tenantId);
72
- for (const row of rows) {
73
- if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
74
- await deleteHandedOverBinary(ctx, row.storageKey);
75
- }
76
- const result = await crud.forget({ id: row.id }, user, ctx.db);
77
- // Executor writes return {isSuccess:false} instead of throwing — a
78
- // discarded result would report this destroy stage "succeeded" while the
79
- // row (and its PII fileName) survives. Throw so the pipeline's
80
- // retry/abandon handling sees it.
81
- if (!result.isSuccess) {
82
- throw new Error(`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${result.error.message}`);
83
- }
84
- }
79
+ let firstFailure;
80
+ // Converges without a cursor: forget() hard-deletes, so each batch query
81
+ // returns the next rows.
82
+ const result = await runChunkedMigration({
83
+ nextBatch: () => ctx.db.selectMany(fileRefsTable, { tenantId: ctx.tenantId }, { limit: DESTROY_CHUNK_SIZE }),
84
+ migrateRow: async (row) => {
85
+ if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
86
+ await deleteHandedOverBinary(ctx, row.storageKey);
87
+ }
88
+ const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
89
+ // Executor writes return {isSuccess:false} instead of throwing — a
90
+ // discarded result would report this destroy stage "succeeded" while the
91
+ // row (and its PII fileName) survives. Throw so the pipeline's
92
+ // retry/abandon handling sees it.
93
+ if (!forgotten.isSuccess) {
94
+ throw new Error(`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`);
95
+ }
96
+ return "migrated";
97
+ },
98
+ maxFailures: 1,
99
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
100
+ onRowError: (_row, err) => {
101
+ firstFailure = err instanceof Error ? err.message : String(err);
102
+ },
103
+ });
104
+ return toDestroyHookResult(result, () => firstFailure);
85
105
  };
86
106
  // The bytes live under the tenant that uploaded them (`<tenantId>/…` or
87
107
  // `exports/<tenantId>/…`), so with per-tenant storage config that tenant's
@@ -140,16 +160,32 @@ export const fileRefStorageDestroyHook = async (tenantId, ctx) => {
140
160
  // it and retries up to the stage's attempt cap before abandoning the destroy
141
161
  // (list+delete are idempotent, so a retry converges rather than
142
162
  // double-deleting or erroring on a missing key).
163
+ let processed = 0;
143
164
  for (const prefix of tenantStoragePrefixes(tenantId)) {
144
165
  const keys = await provider.list(prefix);
145
166
  const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
146
- for (const key of keys) {
147
- if (keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)))
148
- continue;
149
- assertSafeStorageKey(key);
150
- await provider.delete(key);
151
- }
167
+ const deletableKeys = keys.filter((key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)));
168
+ const pending = [...deletableKeys];
169
+ let firstFailure;
170
+ const result = await runChunkedMigration({
171
+ nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
172
+ migrateRow: async (key) => {
173
+ assertSafeStorageKey(key);
174
+ await provider.delete(key);
175
+ return "migrated";
176
+ },
177
+ maxFailures: 1,
178
+ deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
179
+ onRowError: (_key, err) => {
180
+ firstFailure = err instanceof Error ? err.message : String(err);
181
+ },
182
+ });
183
+ const hookResult = toDestroyHookResult(result, () => firstFailure);
184
+ processed += result.migrated;
185
+ if (!hookResult.done)
186
+ return { done: false, processed };
152
187
  }
188
+ return { done: true, processed };
153
189
  };
154
190
  // Any listed key that is itself a surviving row's exact storageKey means a
155
191
  // foreign tenant still owns it (see header comment) — its stem prefix then
@@ -1,5 +1,6 @@
1
1
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
2
2
  import * as z from "zod";
3
+ import { hostRowIsGone } from "../../shared/index.js";
3
4
  import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
4
5
  import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
5
6
  const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
@@ -10,18 +11,44 @@ const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
10
11
  // see 658/1). Block server-side instead: this is the same "referential
11
12
  // integrity has no FK here" reasoning set-folder.write.ts already applies to
12
13
  // folderId on assignment-creation.
14
+ //
15
+ // Assignments whose host is gone (hard-deleted, soft-deleted or unregistered)
16
+ // are cleaned up here instead of blocking forever: clear-folder can't reach
17
+ // them (it requires a visible host). A host-delete hook would break restore,
18
+ // so the cleanup runs lazily at folder:delete.
19
+ const ASSIGNMENT_PAGE_SIZE = 200;
13
20
  export function createDeleteFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
14
21
  return {
15
22
  name: "folder:delete",
16
23
  schema: deleteFolderPayloadSchema,
17
24
  access,
18
- description: "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.",
25
+ description: "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.",
19
26
  agent: { risk: "high" },
20
27
  handler: async (event, ctx) => {
21
28
  const payload = event.payload; // @cast-boundary engine-payload
22
- const assigned = await folderAssignmentExecutor.list({ filter: { field: "folderId", op: "eq", value: payload.id }, limit: 1 }, event.user, ctx.db);
23
- if (assigned.rows.length > 0) {
24
- return writeFailure(new UnprocessableError("folder_has_assignments", { details: { folderId: payload.id } }));
29
+ const orphanAssignmentIds = [];
30
+ let cursor;
31
+ do {
32
+ const page = await folderAssignmentExecutor.list({
33
+ filter: { field: "folderId", op: "eq", value: payload.id },
34
+ limit: ASSIGNMENT_PAGE_SIZE,
35
+ cursor,
36
+ }, event.user, ctx.db);
37
+ for (const row of page.rows) {
38
+ const gone = await hostRowIsGone(ctx.registry, String(row["entityType"]), String(row["entityId"]), ctx.db);
39
+ if (!gone) {
40
+ return writeFailure(new UnprocessableError("folder_has_assignments", {
41
+ details: { folderId: payload.id },
42
+ }));
43
+ }
44
+ orphanAssignmentIds.push(String(row["id"]));
45
+ }
46
+ cursor = page.nextCursor ?? undefined;
47
+ } while (cursor !== undefined);
48
+ for (const id of orphanAssignmentIds) {
49
+ const removed = await folderAssignmentExecutor.delete({ id }, event.user, ctx.db);
50
+ if (!removed.isSuccess)
51
+ return removed;
25
52
  }
26
53
  return folderExecutor.delete({ id: payload.id }, event.user, ctx.db);
27
54
  },
@@ -1,3 +1,3 @@
1
1
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
2
2
  export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
3
- export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
3
+ export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
@@ -2,4 +2,4 @@
2
2
  // Foundation packages (ai-foundation, mail-foundation, file-foundation).
3
3
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
4
4
  export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
5
- export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
5
+ export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
@@ -1,5 +1,9 @@
1
1
  import type { lookup } from "node:dns/promises";
2
+ import * as z from "zod";
2
3
  export declare const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
4
+ export declare const mailAllowedPrivateHostsEnvSchema: z.ZodObject<{
5
+ KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS: z.ZodOptional<z.ZodString>;
6
+ }, z.core.$strip>;
3
7
  export declare function readAllowedPrivateMailHostsFromEnv(env?: Readonly<Record<string, string | undefined>>): readonly string[];
4
8
  export type MailHostGuardOptions = {
5
9
  readonly allowedPrivateMailHosts?: readonly string[];
@@ -10,14 +10,23 @@
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
  import { isIP } from "node:net";
18
16
  import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
17
+ import * as z from "zod";
19
18
  import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
20
19
  export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
20
+ // One schema instance, registered by every feature that reads the variable:
21
+ // composeEnvSchema accepts the same field instance from several features, so
22
+ // an app mounting only one of them still gets the variable validated and
23
+ // documented.
24
+ export const mailAllowedPrivateHostsEnvSchema = z.object({
25
+ [MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
26
+ .string()
27
+ .optional()
28
+ .describe("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."),
29
+ });
21
30
  export function readAllowedPrivateMailHostsFromEnv(env = process.env) {
22
31
  return readHostAllowlistFromEnv(MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, env);
23
32
  }
@@ -22,6 +22,7 @@
22
22
  // Credentials: JSON-Dokument im per-Account-Secret-Slot
23
23
  // (credential-document.ts) — kein Tenant-Config, weil mehrere Accounts
24
24
  // pro Tenant verschiedene Hosts haben.
25
+ import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
25
26
  import { INBOUND_MAIL_PROVIDER_EXTENSION, InboundAuthError, inboundCredentialSecretKey, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
26
27
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
27
28
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
@@ -225,5 +226,6 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
225
226
  });
226
227
  r.requires("inbound-mail-foundation");
227
228
  r.requires("secrets");
229
+ r.envSchema(mailAllowedPrivateHostsEnvSchema);
228
230
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
229
231
  });
@@ -21,10 +21,9 @@ const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed
21
21
  // =============================================================================
22
22
  // Operator escape hatch for an internal relay or a dev/test IMAP server
23
23
  // (greenmail): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, the same operator env
24
- // var mail-transport-smtp declares (see its envSchema — composeEnvSchema
25
- // rejects two features declaring the same key, so this feature reads it
26
- // without redeclaring it), never a tenant-config value, so a tenant can
27
- // never grant themselves the private-host bypass.
24
+ // var mail-transport-smtp registers (both share one schema instance, see
25
+ // foundation-shared), never a tenant-config value, so a tenant can never
26
+ // grant themselves the private-host bypass.
28
27
  //
29
28
  // mailHostLookup is a test-only DNS seam — production leaves it undefined,
30
29
  // so resolveMailConnectTarget uses the real resolver. Module-global state:
@@ -8,6 +8,7 @@ export declare const JobQueries: {
8
8
  readonly details: "jobs:query:details";
9
9
  readonly catalog: "jobs:query:catalog";
10
10
  readonly failures: "jobs:query:failures";
11
+ readonly tenantRuns: "jobs:query:tenant-runs";
11
12
  };
12
13
  export declare const JobErrors: {
13
14
  readonly unknownJob: "unknown_job";
@@ -12,6 +12,7 @@ export const JobQueries = {
12
12
  details: "jobs:query:details",
13
13
  catalog: "jobs:query:catalog",
14
14
  failures: "jobs:query:failures",
15
+ tenantRuns: "jobs:query:tenant-runs",
15
16
  };
16
17
  // Error codes
17
18
  export const JobErrors = {
@@ -3,5 +3,6 @@ export type JobRunRetentionResult = {
3
3
  readonly runsDeleted: number;
4
4
  readonly logsDeleted: number;
5
5
  readonly tenantFailuresDeleted: number;
6
+ readonly tenantRunsDeleted: number;
6
7
  };
7
8
  export declare function deleteStaleJobRuns(db: DbConnection, retentionDays: number): Promise<JobRunRetentionResult>;
@@ -12,6 +12,7 @@
12
12
  import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import { jobRunLogsTable, jobRunsTable } from "../../job-run-table.js";
14
14
  import { tenantJobFailuresTable } from "../../tenant-job-failure-table.js";
15
+ import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
15
16
  const RETENTION_DELETE_BATCH_SIZE = 500;
16
17
  export async function deleteStaleJobRuns(db, retentionDays) {
17
18
  const cutoff = Temporal.Now.instant().subtract({ hours: retentionDays * 24 });
@@ -21,9 +22,13 @@ export async function deleteStaleJobRuns(db, retentionDays) {
21
22
  // they point at is gone by now, and a job whose subject never ran again
22
23
  // would otherwise keep its record forever.
23
24
  const tenantFailuresResult = await deleteManyBatched(db, tenantJobFailuresTable, { failedAt: { lt: cutoff } }, { limit: RETENTION_DELETE_BATCH_SIZE });
25
+ // Only finished rows: active ones are the stale sweep's business, and a
26
+ // still-running job older than the window must not vanish from the tenant.
27
+ const tenantRunsResult = await deleteManyBatched(db, tenantJobRunsTable, { status: { in: ["completed", "failed"] }, updatedAt: { lt: cutoff } }, { limit: RETENTION_DELETE_BATCH_SIZE });
24
28
  return {
25
29
  runsDeleted: runsResult.deleted,
26
30
  logsDeleted: logsResult.deleted,
27
31
  tenantFailuresDeleted: tenantFailuresResult.deleted,
32
+ tenantRunsDeleted: tenantRunsResult.deleted,
28
33
  };
29
34
  }
@@ -2,5 +2,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  export declare const STALE_JOB_RUN_ERROR = "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
3
3
  export type StaleRunSweepResult = {
4
4
  readonly runsMarkedFailed: number;
5
+ readonly tenantRunsMarkedFailed: number;
6
+ readonly tenantQueuedDropped: number;
5
7
  };
6
8
  export declare function markStaleJobRunsFailed(db: DbConnection, timeoutHours: number): Promise<StaleRunSweepResult>;
@@ -11,11 +11,13 @@
11
11
  // schema/migration change, no web filter-dropdown/status-enum update, and
12
12
  // the run becomes retriable via the existing jobs:write:retry gate
13
13
  // (retry.write.ts only allows retry from "failed").
14
- import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { deleteManyBatched, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { mapWithConcurrency } from "../../../shared/index.js";
16
16
  import { encryptFailureError } from "../../job-run-logger.js";
17
17
  import { jobRunsTable } from "../../job-run-table.js";
18
+ import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
18
19
  const KMS_POOL_CONCURRENCY = 4;
20
+ const STALE_DELETE_BATCH_SIZE = 500;
19
21
  export const STALE_JOB_RUN_ERROR = "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
20
22
  export async function markStaleJobRunsFailed(db, timeoutHours) {
21
23
  const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
@@ -57,5 +59,14 @@ export async function markStaleJobRunsFailed(db, timeoutHours) {
57
59
  return updated.length;
58
60
  });
59
61
  const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
60
- return { runsMarkedFailed };
62
+ // Tenant-visible run state follows the same clock: a running row whose
63
+ // worker died is a failure, a queued row that never started (job lost from
64
+ // Redis, enqueue hook without a run) would otherwise show "waiting" forever.
65
+ const tenantRunsFailed = await updateMany(db, tenantJobRunsTable, { status: "failed", finishedAt: now, updatedAt: now }, { status: "running", startedAt: { lt: cutoff } });
66
+ const tenantQueuedStale = await deleteManyBatched(db, tenantJobRunsTable, { status: "queued", updatedAt: { lt: cutoff } }, { limit: STALE_DELETE_BATCH_SIZE });
67
+ return {
68
+ runsMarkedFailed,
69
+ tenantRunsMarkedFailed: tenantRunsFailed.length,
70
+ tenantQueuedDropped: tenantQueuedStale.deleted,
71
+ };
61
72
  }
@@ -9,15 +9,17 @@ import { createRetentionCleanupJob, DEFAULT_JOB_RUN_RETENTION_DAYS, } from "./ha
9
9
  import { retryWrite } from "./handlers/retry.write.js";
10
10
  import { createStaleRunSweepJob, DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS, } from "./handlers/stale-run-sweep.job.js";
11
11
  import { tenantFailuresQuery } from "./handlers/tenant-failures.query.js";
12
+ import { tenantRunsQuery } from "./handlers/tenant-runs.query.js";
12
13
  import { triggerWrite } from "./handlers/trigger.write.js";
13
14
  import { JOBS_I18N } from "./i18n.js";
14
15
  import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.js";
15
16
  import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table.js";
17
+ import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
16
18
  export function createJobsFeature(options = {}) {
17
19
  const retentionDays = options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS;
18
20
  const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
19
21
  return defineFeature("jobs", (r) => {
20
- r.describe("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).");
22
+ r.describe("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.");
21
23
  r.uiHints({
22
24
  displayLabel: "Jobs · Audit & Operator UI",
23
25
  category: "operations",
@@ -38,6 +40,9 @@ export function createJobsFeature(options = {}) {
38
40
  r.storeTable(tenantJobFailuresTableMeta, {
39
41
  reason: "direct_write.tenant_job_failures",
40
42
  });
43
+ r.storeTable(tenantJobRunsTableMeta, {
44
+ reason: "direct_write.tenant_job_runs",
45
+ });
41
46
  // Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
42
47
  r.job("projectionRebuild", { trigger: { manual: true }, concurrency: "skip", schema: projectionRebuildPayloadSchema }, projectionRebuildJob);
43
48
  // Retroactive search backfill (#1206/#1215) — manual + perTenant, so one
@@ -68,6 +73,7 @@ export function createJobsFeature(options = {}) {
68
73
  detail: r.queryHandler(detailQuery),
69
74
  catalog: r.queryHandler(catalogQuery),
70
75
  failures: r.queryHandler(tenantFailuresQuery),
76
+ tenantRuns: r.queryHandler(tenantRunsQuery),
71
77
  };
72
78
  const systemAdminAccess = { roles: ["SystemAdmin"] };
73
79
  r.translations({ keys: JOBS_I18N });
@@ -56,7 +56,7 @@ export const listQuery = defineQueryHandler({
56
56
  .optional(),
57
57
  sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
58
58
  sortDirection: z.enum(["asc", "desc"]).optional(),
59
- limit: z.number().optional(),
59
+ limit: z.number().int().min(1).optional(),
60
60
  cursor: z.string().optional(),
61
61
  totalCount: z.boolean().optional(),
62
62
  }),
@@ -26,7 +26,7 @@ export const tenantFailuresQuery = defineQueryHandler({
26
26
  schema: z.object({
27
27
  jobName: z.string().optional(),
28
28
  subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
29
- limit: z.number().min(1).max(200).optional(),
29
+ limit: z.number().int().min(1).max(200).optional(),
30
30
  }),
31
31
  // Every membership rank: a failure record carries a job name and a
32
32
  // translation key, nothing a team member of the tenant may not see.
@@ -0,0 +1,17 @@
1
+ import * as z from "zod";
2
+ import { type TenantJobRunStatus } from "../tenant-job-run-table.js";
3
+ export declare const tenantRunsQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"tenant-runs", z.ZodObject<{
4
+ jobName: z.ZodOptional<z.ZodString>;
5
+ subject: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
6
+ limit: z.ZodOptional<z.ZodNumber>;
7
+ }, z.core.$strip>, {
8
+ rows: {
9
+ jobName: string;
10
+ subject: Record<string, unknown> | null;
11
+ status: TenantJobRunStatus;
12
+ queuedAt: Temporal.Instant | null;
13
+ startedAt: Temporal.Instant | null;
14
+ finishedAt: Temporal.Instant | null;
15
+ }[];
16
+ nextCursor: null;
17
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;