@pattern-stack/codegen 0.8.1 → 0.9.1

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 (120) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/{job-orchestrator.protocol-BwsBd37o.d.ts → job-orchestrator.protocol-CHOEqBDk.d.ts} +36 -1
  3. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.d.ts +2 -2
  4. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
  5. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
  6. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +5 -1
  7. package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
  8. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  9. package/dist/runtime/subsystems/bridge/event-flow.service.d.ts +1 -1
  10. package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
  11. package/dist/runtime/subsystems/bridge/index.d.ts +4 -1
  12. package/dist/runtime/subsystems/bridge/index.js +837 -182
  13. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  14. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
  15. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
  16. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
  17. package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
  18. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
  19. package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
  20. package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
  21. package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
  22. package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
  23. package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
  24. package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
  25. package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
  26. package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
  27. package/dist/runtime/subsystems/events/events.module.js +177 -3
  28. package/dist/runtime/subsystems/events/events.module.js.map +1 -1
  29. package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
  30. package/dist/runtime/subsystems/events/events.tokens.js +2 -0
  31. package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
  32. package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
  33. package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
  34. package/dist/runtime/subsystems/events/index.d.ts +2 -1
  35. package/dist/runtime/subsystems/events/index.js +178 -3
  36. package/dist/runtime/subsystems/events/index.js.map +1 -1
  37. package/dist/runtime/subsystems/index.d.ts +3 -2
  38. package/dist/runtime/subsystems/index.js +1194 -264
  39. package/dist/runtime/subsystems/index.js.map +1 -1
  40. package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
  41. package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
  42. package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
  43. package/dist/runtime/subsystems/jobs/index.d.ts +8 -3
  44. package/dist/runtime/subsystems/jobs/index.js +861 -201
  45. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  46. package/dist/runtime/subsystems/jobs/job-handler.base.d.ts +2 -1
  47. package/dist/runtime/subsystems/jobs/job-handler.base.js.map +1 -1
  48. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +108 -0
  49. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
  50. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
  51. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +2 -1
  52. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +2 -1
  53. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
  54. package/dist/runtime/subsystems/jobs/job-orchestrator.protocol.d.ts +2 -1
  55. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +53 -0
  56. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
  57. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
  58. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +4 -2
  59. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
  60. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
  61. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +4 -2
  62. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
  63. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
  64. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +76 -2
  65. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +49 -0
  66. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
  67. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
  68. package/dist/runtime/subsystems/jobs/job-worker.d.ts +2 -1
  69. package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
  70. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +44 -5
  71. package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
  72. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  73. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
  74. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
  75. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  76. package/dist/runtime/subsystems/jobs/jobs-errors.d.ts +2 -1
  77. package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
  78. package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
  79. package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
  80. package/dist/runtime/subsystems/observability/index.d.ts +4 -3
  81. package/dist/runtime/subsystems/observability/index.js +109 -2
  82. package/dist/runtime/subsystems/observability/index.js.map +1 -1
  83. package/dist/runtime/subsystems/observability/observability.module.js +109 -2
  84. package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
  85. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +64 -3
  86. package/dist/runtime/subsystems/observability/observability.service.d.ts +22 -4
  87. package/dist/runtime/subsystems/observability/observability.service.js +109 -2
  88. package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
  89. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +3 -2
  90. package/dist/runtime/subsystems/observability/reporters/index.d.ts +3 -2
  91. package/dist/src/cli/index.js +30 -6
  92. package/dist/src/cli/index.js.map +1 -1
  93. package/package.json +5 -1
  94. package/runtime/subsystems/bridge/bridge.module.ts +5 -0
  95. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
  96. package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
  97. package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
  98. package/runtime/subsystems/events/event-read.protocol.ts +97 -0
  99. package/runtime/subsystems/events/events.module.ts +18 -2
  100. package/runtime/subsystems/events/events.tokens.ts +16 -0
  101. package/runtime/subsystems/events/index.ts +7 -0
  102. package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
  103. package/runtime/subsystems/jobs/index.ts +22 -0
  104. package/runtime/subsystems/jobs/job-handler.base.ts +36 -0
  105. package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
  106. package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
  107. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
  108. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
  109. package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
  110. package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
  111. package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
  112. package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
  113. package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
  114. package/runtime/subsystems/observability/index.ts +8 -0
  115. package/runtime/subsystems/observability/observability.protocol.ts +76 -0
  116. package/runtime/subsystems/observability/observability.service.ts +148 -1
  117. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
  118. package/templates/relationship/new/prompt.js +8 -5
  119. package/templates/subsystem/jobs/worker.ejs.t +30 -7
  120. package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../runtime/subsystems/jobs/job-run-service.memory-backend.ts","../../../../runtime/subsystems/jobs/jobs-domain.tokens.ts","../../../../runtime/subsystems/jobs/jobs-errors.ts"],"sourcesContent":["/**\n * MemoryJobRunService — scope-oriented queries and bulk ops over the\n * in-memory run store (ADR-022, JOB-4).\n *\n * Mirrors `DrizzleJobRunService` but scans `MemoryJobStore.runs.values()`.\n * Cancel delegates back to the orchestrator so cascade semantics stay in\n * one place.\n */\nimport { Inject, Injectable } from '@nestjs/common';\nimport type { JobRunRow } from './job-orchestration.schema';\nimport type { JobRun } from './job-orchestrator.protocol';\nimport type {\n IJobRunService,\n ListForScopeOptions,\n CancelForScopeOptions,\n RescheduleForScopeOptions,\n PoolStatusCount,\n JobRunFailure,\n} from './job-run-service.protocol';\nimport type { IJobOrchestrator } from './job-orchestrator.protocol';\nimport { JOB_ORCHESTRATOR, JOBS_MULTI_TENANT } from './jobs-domain.tokens';\nimport { MissingTenantIdError } from './jobs-errors';\nimport { MemoryJobStore } from './memory-job-store';\n\nconst NON_TERMINAL_STATUSES: JobRunRow['status'][] = [\n 'pending',\n 'running',\n 'waiting',\n];\n\n@Injectable()\nexport class MemoryJobRunService implements IJobRunService {\n constructor(\n private readonly store: MemoryJobStore,\n @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,\n @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,\n ) {}\n\n /**\n * JOB-8 — produce a per-row predicate for the tenant gate.\n * Returns `null` when multi-tenancy is off (caller doesn't check).\n * Throws when on + `undefined`; matches `tenant_id IS NULL` on explicit\n * `null` to support cross-tenant background work.\n */\n private tenantPredicate(\n method: string,\n tenantId: string | null | undefined,\n ): ((r: JobRunRow) => boolean) | null {\n if (!this.multiTenant) return null;\n if (tenantId === undefined) throw new MissingTenantIdError(method);\n return (r) => r.tenantId === tenantId;\n }\n\n async listForScope(\n entityType: string,\n entityId: string,\n opts: ListForScopeOptions = {},\n ): Promise<JobRun[]> {\n const statusFilter = opts.status\n ? Array.isArray(opts.status)\n ? new Set(opts.status)\n : new Set([opts.status])\n : null;\n const tenantCheck = this.tenantPredicate('listForScope', opts.tenantId);\n\n const rows: JobRunRow[] = [];\n for (const r of this.store.runs.values()) {\n if (r.scopeEntityType !== entityType) continue;\n if (r.scopeEntityId !== entityId) continue;\n if (statusFilter && !statusFilter.has(r.status)) continue;\n if (opts.jobType && r.jobType !== opts.jobType) continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n rows.push(r);\n }\n\n const orderBy = opts.orderBy ?? 'created_at desc';\n rows.sort((a, b) => compareBy(a, b, orderBy));\n\n const offset = opts.offset ?? 0;\n const limit = opts.limit;\n const sliced =\n typeof limit === 'number' ? rows.slice(offset, offset + limit) : rows.slice(offset);\n return sliced as JobRun[];\n }\n\n async cancelForScope(\n entityType: string,\n entityId: string,\n opts: CancelForScopeOptions = {},\n ): Promise<void> {\n const tenantCheck = this.tenantPredicate('cancelForScope', opts.tenantId);\n\n const ids: string[] = [];\n for (const r of this.store.runs.values()) {\n if (r.scopeEntityType !== entityType) continue;\n if (r.scopeEntityId !== entityId) continue;\n if (!NON_TERMINAL_STATUSES.includes(r.status)) continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n ids.push(r.id);\n }\n for (const id of ids) {\n // Propagate the tenant gate through the orchestrator's cancel so the\n // internal per-row guard passes (no surprise MissingTenantIdError\n // once the scope query has already narrowed to this tenant).\n await this.orchestrator.cancel(id, {\n cascade: true,\n tenantId: opts.tenantId,\n });\n }\n }\n\n async rescheduleForScope(\n entityType: string,\n entityId: string,\n newRunAt: Date,\n opts: RescheduleForScopeOptions = {},\n ): Promise<void> {\n const tenantCheck = this.tenantPredicate('rescheduleForScope', opts.tenantId);\n for (const r of this.store.runs.values()) {\n if (r.scopeEntityType !== entityType) continue;\n if (r.scopeEntityId !== entityId) continue;\n if (r.status !== 'pending') continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n this.store.runs.set(r.id, {\n ...r,\n runAt: newRunAt,\n updatedAt: new Date(),\n });\n }\n }\n\n async countByPoolAndStatus(\n tenantId?: string | null,\n ): Promise<PoolStatusCount[]> {\n const tenantCheck = this.tenantPredicate('countByPoolAndStatus', tenantId);\n const map = new Map<string, PoolStatusCount>();\n for (const r of this.store.runs.values()) {\n if (tenantCheck && !tenantCheck(r)) continue;\n const key = `${r.pool}\\0${r.status}`;\n const cur = map.get(key);\n if (cur) {\n cur.count += 1;\n } else {\n map.set(key, { pool: r.pool, status: r.status, count: 1 });\n }\n }\n return Array.from(map.values());\n }\n\n async listRecentFailed(\n limit: number,\n tenantId?: string | null,\n ): Promise<JobRunFailure[]> {\n const tenantCheck = this.tenantPredicate('listRecentFailed', tenantId);\n const failed: JobRunRow[] = [];\n for (const r of this.store.runs.values()) {\n if (r.status !== 'failed') continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n failed.push(r);\n }\n failed.sort((a, b) => {\n const at = (a.finishedAt ?? a.updatedAt).getTime();\n const bt = (b.finishedAt ?? b.updatedAt).getTime();\n return bt - at;\n });\n return failed.slice(0, limit).map((r) => ({\n runId: r.id,\n jobType: r.jobType,\n pool: r.pool,\n scopeEntityType: r.scopeEntityType,\n scopeEntityId: r.scopeEntityId,\n tenantId: r.tenantId,\n attempts: r.attempts,\n errorMessage: r.error?.message ?? null,\n failedAt: r.finishedAt ?? r.updatedAt,\n createdAt: r.createdAt,\n }));\n }\n\n /**\n * Direct lookup. Not on the protocol — concrete-class convenience for\n * tests. Matches `DrizzleJobRunService.findByRootRunId` in spirit; both\n * are debug / test helpers that sidestep the orchestrator.\n */\n findById(runId: string): JobRun | null {\n return (this.store.runs.get(runId) ?? null) as JobRun | null;\n }\n\n /** Public counterpart to the Drizzle backend's `findByRootRunId` helper. */\n findByRootRunId(rootRunId: string): JobRun[] {\n const out: JobRunRow[] = [];\n for (const r of this.store.runs.values()) {\n if (r.rootRunId === rootRunId) out.push(r);\n }\n return out as JobRun[];\n }\n}\n\nfunction compareBy(\n a: JobRunRow,\n b: JobRunRow,\n order: Exclude<ListForScopeOptions['orderBy'], undefined>,\n): number {\n switch (order) {\n case 'created_at asc':\n return a.createdAt.getTime() - b.createdAt.getTime();\n case 'run_at desc':\n return b.runAt.getTime() - a.runAt.getTime();\n case 'run_at asc':\n return a.runAt.getTime() - b.runAt.getTime();\n case 'created_at desc':\n default:\n return b.createdAt.getTime() - a.createdAt.getTime();\n }\n}\n","/**\n * Injection tokens for the job orchestration domain layer (ADR-022, JOB-2).\n *\n * Consumer code injects these symbols via `@Inject(JOB_ORCHESTRATOR)` etc.;\n * concrete backends (JOB-3 Drizzle, JOB-4 Memory) provide the implementations\n * through `JobsDomainModule.forRoot({ backend })` in JOB-5.\n *\n * Each token is a unique `Symbol` — guaranteed distinct from every other\n * Symbol at runtime, which is exactly the uniqueness guarantee Nest's DI\n * container relies on for token-based lookup.\n */\nexport const JOB_ORCHESTRATOR = Symbol('JOB_ORCHESTRATOR');\nexport const JOB_RUN_SERVICE = Symbol('JOB_RUN_SERVICE');\nexport const JOB_STEP_SERVICE = Symbol('JOB_STEP_SERVICE');\n\n/**\n * Multi-tenancy opt-in flag (JOB-8). Bound to the boolean passed in via\n * `JobsDomainModule.forRoot({ multiTenant })`, defaulting to `false`.\n *\n * When `true`, the four service-layer backends (Drizzle + Memory orchestrator\n * and run-service) enforce `tenantId` on every mutating / targeted-read call:\n * `start`, `cancel`, `listForScope`, `cancelForScope`, `rescheduleForScope`.\n * Missing (`undefined`) `tenantId` throws `MissingTenantIdError`; explicit\n * `null` opts into cross-tenant background work and passes through.\n *\n * The JobWorker claim loop is **cross-tenant by design** — the worker has no\n * tenant context; `tenantId` is populated at write time and enforced on\n * targeted reads. See docs/specs/JOB-8.md.\n */\nexport const JOBS_MULTI_TENANT = Symbol('JOBS_MULTI_TENANT');\n","/**\n * Typed errors for the job orchestration domain (ADR-022, JOB-3).\n *\n * All thrown by the Drizzle orchestrator (and mirrored by the Memory\n * backend in JOB-4). They exist as classes so consumers can `instanceof`\n * them in catch blocks and exception filters can map them to HTTP codes.\n */\nimport type { JobRun } from './job-orchestrator.protocol';\n\n/**\n * `start(type, …)` was called for a job type that has no row in the `job`\n * table. At runtime this usually means the handler was not decorated or the\n * boot validator (JOB-5) has not registered it yet.\n */\nexport class JobTypeNotFoundError extends Error {\n override readonly name = 'JobTypeNotFoundError';\n constructor(public readonly jobType: string) {\n super(`No job definition registered for type '${jobType}'.`);\n }\n}\n\n/**\n * Thrown by `start` when `collision_mode === 'reject'` and a non-terminal\n * run with the same `concurrency_key` already exists. Carries the incumbent\n * so callers can surface its id or subscribe to its completion event.\n */\nexport class JobCollisionError extends Error {\n override readonly name = 'JobCollisionError';\n constructor(\n public readonly jobType: string,\n public readonly concurrencyKey: string,\n public readonly incumbent: JobRun,\n ) {\n super(\n `Job type '${jobType}' has an in-flight run with concurrency_key ` +\n `'${concurrencyKey}' (incumbent ${incumbent.id}); collision_mode=reject.`,\n );\n }\n}\n\n/**\n * `replay` was called on a run that is not in a replayable terminal state\n * (i.e. still `pending` / `running` / `waiting`). Replay always spawns\n * fresh execution and therefore requires the source run to be settled.\n */\nexport class JobNotReplayableError extends Error {\n override readonly name = 'JobNotReplayableError';\n constructor(\n public readonly runId: string,\n public readonly currentStatus: string,\n ) {\n super(\n `Run ${runId} is not replayable from status '${currentStatus}'. ` +\n `Only 'completed', 'failed', 'timed_out', and 'canceled' are eligible.`,\n );\n }\n}\n\n/**\n * A `concurrency_key_template` or `dedupe_key_template` referenced a field\n * that is not present on the input payload. Caught at `start` time so the\n * caller sees the misconfiguration synchronously rather than at claim time.\n */\nexport class JobTemplateFieldMissingError extends Error {\n override readonly name = 'JobTemplateFieldMissingError';\n constructor(\n public readonly template: string,\n public readonly field: string,\n ) {\n super(\n `Template '${template}' references input field '${field}' which is ` +\n `missing or undefined on the payload.`,\n );\n }\n}\n\n/**\n * Thrown by the four multi-tenant-aware service-layer backends (JOB-8)\n * when `JobsDomainModule` was configured with `multiTenant: true` but the\n * caller did not pass a `tenantId` in the relevant options object.\n *\n * **Strict enforcement rationale (resolved 2026-04-18).** Cross-tenant data\n * leakage is the worst class of bug a multi-tenant system can ship; surfacing\n * the misuse loudly at the call site (rather than silently defaulting to\n * `null` or to the \"last tenant seen\") prevents both accidental global\n * writes and sneaky reads that return a union of tenants.\n *\n * - `undefined` `tenantId` → throw this error.\n * - Explicit `null` `tenantId` → passes; opts the call into cross-tenant\n * background work (e.g. a nightly housekeeping job that must scan all\n * tenants). The row is persisted with `tenant_id = NULL`.\n */\nexport class MissingTenantIdError extends Error {\n override readonly name = 'MissingTenantIdError';\n constructor(public readonly method: string) {\n super(\n `MissingTenantIdError: JobsDomainModule was configured with ` +\n `multiTenant=true but ${method} was called without tenantId ` +\n `(undefined). Pass an explicit tenantId, or pass null for ` +\n `cross-tenant work.`,\n );\n }\n}\n\n/**\n * Thrown by `JobWorkerModule.onModuleInit` (Drizzle backend only) when the\n * `job` table contains type rows for which no `@JobHandler` is registered\n * in the running process. Surfaces every orphaned type at once so a single\n * boot tells the operator everything to clean up.\n *\n * Skipped entirely in memory mode (Q4 resolution 2026-04-19) — the memory\n * backend has no DB rows to validate; `MemoryJobOrchestrator.start()`\n * throws `JobTypeNotFoundError` synchronously for unknown types instead.\n */\nexport class BootValidationError extends Error {\n override readonly name = 'BootValidationError';\n constructor(public readonly missingHandlers: string[]) {\n super(\n `BootValidationError: ${missingHandlers.length} orphaned job type(s) ` +\n `in 'job' table with no matching @JobHandler in the running process: ` +\n `[${missingHandlers.join(', ')}]. Either register the handler(s) or ` +\n `remove the rows.`,\n );\n }\n}\n\n/**\n * Thrown by `JobWorkerModule.onModuleInit` when one or more `@JobHandler`\n * classes target a `reserved: true` pool from the resolved pool config\n * (the three `events_*` pools are reserved for the events subsystem\n * outbox drain). Listing every offender on a single boot avoids the\n * fix-one-restart-fix-next loop.\n */\nexport class ReservedPoolViolationError extends Error {\n override readonly name = 'ReservedPoolViolationError';\n constructor(\n public readonly offenders: ReadonlyArray<{\n handlerClass: string;\n pool: string;\n }>,\n ) {\n super(\n `ReservedPoolViolationError: ${offenders.length} @JobHandler(s) target ` +\n `reserved pools — reserved pools are framework-only:\\n` +\n offenders\n .map((o) => ` - ${o.handlerClass} → pool='${o.pool}'`)\n .join('\\n'),\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../../runtime/subsystems/jobs/job-run-service.memory-backend.ts","../../../../runtime/subsystems/jobs/job-run-keyset-cursor.ts","../../../../runtime/subsystems/jobs/jobs-domain.tokens.ts","../../../../runtime/subsystems/jobs/jobs-errors.ts"],"sourcesContent":["/**\n * MemoryJobRunService — scope-oriented queries and bulk ops over the\n * in-memory run store (ADR-022, JOB-4).\n *\n * Mirrors `DrizzleJobRunService` but scans `MemoryJobStore.runs.values()`.\n * Cancel delegates back to the orchestrator so cascade semantics stay in\n * one place.\n */\nimport { Inject, Injectable } from '@nestjs/common';\nimport type { JobRunRow } from './job-orchestration.schema';\nimport type { JobRun } from './job-orchestrator.protocol';\nimport type {\n IJobRunService,\n ListForScopeOptions,\n CancelForScopeOptions,\n RescheduleForScopeOptions,\n PoolStatusCount,\n JobRunFailure,\n ListJobRunsQuery,\n JobRunPage,\n} from './job-run-service.protocol';\nimport {\n clampLimit,\n decodeKeysetCursor,\n encodeKeysetCursor,\n toJobRunSummary,\n} from './job-run-keyset-cursor';\nimport type { IJobOrchestrator } from './job-orchestrator.protocol';\nimport { JOB_ORCHESTRATOR, JOBS_MULTI_TENANT } from './jobs-domain.tokens';\nimport { MissingTenantIdError } from './jobs-errors';\nimport { MemoryJobStore } from './memory-job-store';\n\nconst NON_TERMINAL_STATUSES: JobRunRow['status'][] = [\n 'pending',\n 'running',\n 'waiting',\n];\n\n@Injectable()\nexport class MemoryJobRunService implements IJobRunService {\n constructor(\n private readonly store: MemoryJobStore,\n @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,\n @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,\n ) {}\n\n /**\n * JOB-8 — produce a per-row predicate for the tenant gate.\n * Returns `null` when multi-tenancy is off (caller doesn't check).\n * Throws when on + `undefined`; matches `tenant_id IS NULL` on explicit\n * `null` to support cross-tenant background work.\n */\n private tenantPredicate(\n method: string,\n tenantId: string | null | undefined,\n ): ((r: JobRunRow) => boolean) | null {\n if (!this.multiTenant) return null;\n if (tenantId === undefined) throw new MissingTenantIdError(method);\n return (r) => r.tenantId === tenantId;\n }\n\n async listForScope(\n entityType: string,\n entityId: string,\n opts: ListForScopeOptions = {},\n ): Promise<JobRun[]> {\n const statusFilter = opts.status\n ? Array.isArray(opts.status)\n ? new Set(opts.status)\n : new Set([opts.status])\n : null;\n const tenantCheck = this.tenantPredicate('listForScope', opts.tenantId);\n\n const rows: JobRunRow[] = [];\n for (const r of this.store.runs.values()) {\n if (r.scopeEntityType !== entityType) continue;\n if (r.scopeEntityId !== entityId) continue;\n if (statusFilter && !statusFilter.has(r.status)) continue;\n if (opts.jobType && r.jobType !== opts.jobType) continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n rows.push(r);\n }\n\n const orderBy = opts.orderBy ?? 'created_at desc';\n rows.sort((a, b) => compareBy(a, b, orderBy));\n\n const offset = opts.offset ?? 0;\n const limit = opts.limit;\n const sliced =\n typeof limit === 'number' ? rows.slice(offset, offset + limit) : rows.slice(offset);\n return sliced as JobRun[];\n }\n\n async cancelForScope(\n entityType: string,\n entityId: string,\n opts: CancelForScopeOptions = {},\n ): Promise<void> {\n const tenantCheck = this.tenantPredicate('cancelForScope', opts.tenantId);\n\n const ids: string[] = [];\n for (const r of this.store.runs.values()) {\n if (r.scopeEntityType !== entityType) continue;\n if (r.scopeEntityId !== entityId) continue;\n if (!NON_TERMINAL_STATUSES.includes(r.status)) continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n ids.push(r.id);\n }\n for (const id of ids) {\n // Propagate the tenant gate through the orchestrator's cancel so the\n // internal per-row guard passes (no surprise MissingTenantIdError\n // once the scope query has already narrowed to this tenant).\n await this.orchestrator.cancel(id, {\n cascade: true,\n tenantId: opts.tenantId,\n });\n }\n }\n\n async rescheduleForScope(\n entityType: string,\n entityId: string,\n newRunAt: Date,\n opts: RescheduleForScopeOptions = {},\n ): Promise<void> {\n const tenantCheck = this.tenantPredicate('rescheduleForScope', opts.tenantId);\n for (const r of this.store.runs.values()) {\n if (r.scopeEntityType !== entityType) continue;\n if (r.scopeEntityId !== entityId) continue;\n if (r.status !== 'pending') continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n this.store.runs.set(r.id, {\n ...r,\n runAt: newRunAt,\n updatedAt: new Date(),\n });\n }\n }\n\n async countByPoolAndStatus(\n tenantId?: string | null,\n ): Promise<PoolStatusCount[]> {\n const tenantCheck = this.tenantPredicate('countByPoolAndStatus', tenantId);\n const map = new Map<string, PoolStatusCount>();\n for (const r of this.store.runs.values()) {\n if (tenantCheck && !tenantCheck(r)) continue;\n const key = `${r.pool}\\0${r.status}`;\n const cur = map.get(key);\n if (cur) {\n cur.count += 1;\n } else {\n map.set(key, { pool: r.pool, status: r.status, count: 1 });\n }\n }\n return Array.from(map.values());\n }\n\n async listRecentFailed(\n limit: number,\n tenantId?: string | null,\n ): Promise<JobRunFailure[]> {\n const tenantCheck = this.tenantPredicate('listRecentFailed', tenantId);\n const failed: JobRunRow[] = [];\n for (const r of this.store.runs.values()) {\n if (r.status !== 'failed') continue;\n if (tenantCheck && !tenantCheck(r)) continue;\n failed.push(r);\n }\n failed.sort((a, b) => {\n const at = (a.finishedAt ?? a.updatedAt).getTime();\n const bt = (b.finishedAt ?? b.updatedAt).getTime();\n return bt - at;\n });\n return failed.slice(0, limit).map((r) => ({\n runId: r.id,\n jobType: r.jobType,\n pool: r.pool,\n scopeEntityType: r.scopeEntityType,\n scopeEntityId: r.scopeEntityId,\n tenantId: r.tenantId,\n attempts: r.attempts,\n errorMessage: r.error?.message ?? null,\n failedAt: r.finishedAt ?? r.updatedAt,\n createdAt: r.createdAt,\n }));\n }\n\n async listJobRuns(query: ListJobRunsQuery = {}): Promise<JobRunPage> {\n const limit = clampLimit(query.limit);\n const tenantCheck = this.tenantPredicate('listJobRuns', query.tenantId);\n const keyset = query.cursor ? decodeKeysetCursor(query.cursor) : null;\n\n const matched: JobRunRow[] = [];\n for (const r of this.store.runs.values()) {\n if (tenantCheck && !tenantCheck(r)) continue;\n if (query.poolId && r.pool !== query.poolId) continue;\n if (query.rootRunId && r.rootRunId !== query.rootRunId) continue;\n if (query.status && r.status !== query.status) continue;\n if (query.since && r.createdAt.getTime() < query.since.getTime()) continue;\n matched.push(r);\n }\n\n // Order created_at DESC, id DESC to match the Drizzle backend's keyset.\n matched.sort((a, b) => {\n const dt = b.createdAt.getTime() - a.createdAt.getTime();\n if (dt !== 0) return dt;\n return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;\n });\n\n // Keyset seek: drop everything at/after the cursor's (created_at, id).\n const seeked = keyset\n ? matched.filter((r) => {\n const ct = r.createdAt.getTime();\n const kt = keyset.createdAt.getTime();\n if (ct < kt) return true;\n if (ct > kt) return false;\n return r.id < keyset.id;\n })\n : matched;\n\n const hasMore = seeked.length > limit;\n const page = hasMore ? seeked.slice(0, limit) : seeked;\n const items = page.map(toJobRunSummary);\n const last = page[page.length - 1];\n const nextCursor =\n hasMore && last\n ? encodeKeysetCursor({ createdAt: last.createdAt, id: last.id })\n : null;\n\n return { items, nextCursor };\n }\n\n /**\n * Direct lookup. Not on the protocol — concrete-class convenience for\n * tests. Matches `DrizzleJobRunService.findByRootRunId` in spirit; both\n * are debug / test helpers that sidestep the orchestrator.\n */\n findById(runId: string): JobRun | null {\n return (this.store.runs.get(runId) ?? null) as JobRun | null;\n }\n\n /** Public counterpart to the Drizzle backend's `findByRootRunId` helper. */\n findByRootRunId(rootRunId: string): JobRun[] {\n const out: JobRunRow[] = [];\n for (const r of this.store.runs.values()) {\n if (r.rootRunId === rootRunId) out.push(r);\n }\n return out as JobRun[];\n }\n}\n\nfunction compareBy(\n a: JobRunRow,\n b: JobRunRow,\n order: Exclude<ListForScopeOptions['orderBy'], undefined>,\n): number {\n switch (order) {\n case 'created_at asc':\n return a.createdAt.getTime() - b.createdAt.getTime();\n case 'run_at desc':\n return b.runAt.getTime() - a.runAt.getTime();\n case 'run_at asc':\n return a.runAt.getTime() - b.runAt.getTime();\n case 'created_at desc':\n default:\n return b.createdAt.getTime() - a.createdAt.getTime();\n }\n}\n","/**\n * Keyset (seek) cursor codec for `IJobRunService.listJobRuns` (OBS-LIST-1).\n *\n * The list is ordered `created_at DESC, id DESC`. The cursor encodes the\n * `(createdAt, id)` of the last row on the previous page so the next page\n * can seek with `WHERE (created_at, id) < (cursorCreatedAt, cursorId)`\n * rather than an `OFFSET`. Keyset pagination stays O(log n) on deep pages\n * and is stable as new rows arrive at the head.\n *\n * The cursor is opaque to consumers: a base64url-encoded JSON tuple. Shape\n * is an implementation detail — never parse it outside this module.\n *\n * Also hosts `toJobRunSummary`, the single `JobRunRow → JobRunSummary`\n * projection shared by both backends so the narrow shape stays in sync.\n */\nimport type { JobRunRow } from './job-orchestration.schema';\nimport type { JobRunSummary } from './job-run-service.protocol';\n\nexport interface JobRunKeyset {\n /** `created_at` of the last row on the previous page. */\n createdAt: Date;\n /** `id` (UUID) tie-break of the last row on the previous page. */\n id: string;\n}\n\n/** Default page size when `limit` is omitted. */\nexport const DEFAULT_LIST_LIMIT = 50;\n/** Hard upper bound on page size to keep a single read bounded. */\nexport const MAX_LIST_LIMIT = 200;\n\n/** Clamp a caller-supplied `limit` into `[1, MAX_LIST_LIMIT]`. */\nexport function clampLimit(limit: number | undefined): number {\n if (typeof limit !== 'number' || !Number.isFinite(limit)) {\n return DEFAULT_LIST_LIMIT;\n }\n const floored = Math.floor(limit);\n if (floored < 1) return 1;\n if (floored > MAX_LIST_LIMIT) return MAX_LIST_LIMIT;\n return floored;\n}\n\nexport function encodeKeysetCursor(keyset: JobRunKeyset): string {\n const tuple = [keyset.createdAt.toISOString(), keyset.id];\n return Buffer.from(JSON.stringify(tuple), 'utf8').toString('base64url');\n}\n\n/**\n * Decode an opaque cursor back into its `(createdAt, id)` keyset. Returns\n * `null` for a malformed cursor so callers can treat garbage input as\n * \"start from the beginning\" rather than throwing on user-supplied data.\n */\nexport function decodeKeysetCursor(cursor: string): JobRunKeyset | null {\n try {\n const json = Buffer.from(cursor, 'base64url').toString('utf8');\n const parsed = JSON.parse(json) as unknown;\n if (!Array.isArray(parsed) || parsed.length !== 2) return null;\n const [iso, id] = parsed;\n if (typeof iso !== 'string' || typeof id !== 'string') return null;\n const createdAt = new Date(iso);\n if (Number.isNaN(createdAt.getTime())) return null;\n return { createdAt, id };\n } catch {\n return null;\n }\n}\n\n/**\n * Project a raw `job_run` row into the narrow `JobRunSummary` shape exposed\n * by `listJobRuns`. `errorMessage` is pulled from the jsonb `error.message`.\n */\nexport function toJobRunSummary(r: JobRunRow): JobRunSummary {\n return {\n runId: r.id,\n rootRunId: r.rootRunId,\n jobType: r.jobType,\n pool: r.pool,\n status: r.status,\n scopeEntityType: r.scopeEntityType,\n scopeEntityId: r.scopeEntityId,\n tenantId: r.tenantId,\n attempts: r.attempts,\n errorMessage: r.error?.message ?? null,\n runAt: r.runAt,\n startedAt: r.startedAt,\n finishedAt: r.finishedAt,\n createdAt: r.createdAt,\n };\n}\n","/**\n * Injection tokens for the job orchestration domain layer (ADR-022, JOB-2).\n *\n * Consumer code injects these symbols via `@Inject(JOB_ORCHESTRATOR)` etc.;\n * concrete backends (JOB-3 Drizzle, JOB-4 Memory) provide the implementations\n * through `JobsDomainModule.forRoot({ backend })` in JOB-5.\n *\n * Each token is a unique `Symbol` — guaranteed distinct from every other\n * Symbol at runtime, which is exactly the uniqueness guarantee Nest's DI\n * container relies on for token-based lookup.\n */\nexport const JOB_ORCHESTRATOR = Symbol('JOB_ORCHESTRATOR');\nexport const JOB_RUN_SERVICE = Symbol('JOB_RUN_SERVICE');\nexport const JOB_STEP_SERVICE = Symbol('JOB_STEP_SERVICE');\n\n/**\n * Multi-tenancy opt-in flag (JOB-8). Bound to the boolean passed in via\n * `JobsDomainModule.forRoot({ multiTenant })`, defaulting to `false`.\n *\n * When `true`, the four service-layer backends (Drizzle + Memory orchestrator\n * and run-service) enforce `tenantId` on every mutating / targeted-read call:\n * `start`, `cancel`, `listForScope`, `cancelForScope`, `rescheduleForScope`.\n * Missing (`undefined`) `tenantId` throws `MissingTenantIdError`; explicit\n * `null` opts into cross-tenant background work and passes through.\n *\n * The JobWorker claim loop is **cross-tenant by design** — the worker has no\n * tenant context; `tenantId` is populated at write time and enforced on\n * targeted reads. See docs/specs/JOB-8.md.\n */\nexport const JOBS_MULTI_TENANT = Symbol('JOBS_MULTI_TENANT');\n","/**\n * Typed errors for the job orchestration domain (ADR-022, JOB-3).\n *\n * All thrown by the Drizzle orchestrator (and mirrored by the Memory\n * backend in JOB-4). They exist as classes so consumers can `instanceof`\n * them in catch blocks and exception filters can map them to HTTP codes.\n */\nimport type { JobRun } from './job-orchestrator.protocol';\n\n/**\n * `start(type, …)` was called for a job type that has no row in the `job`\n * table. At runtime this usually means the handler was not decorated or the\n * boot validator (JOB-5) has not registered it yet.\n */\nexport class JobTypeNotFoundError extends Error {\n override readonly name = 'JobTypeNotFoundError';\n constructor(public readonly jobType: string) {\n super(`No job definition registered for type '${jobType}'.`);\n }\n}\n\n/**\n * Thrown by `start` when `collision_mode === 'reject'` and a non-terminal\n * run with the same `concurrency_key` already exists. Carries the incumbent\n * so callers can surface its id or subscribe to its completion event.\n */\nexport class JobCollisionError extends Error {\n override readonly name = 'JobCollisionError';\n constructor(\n public readonly jobType: string,\n public readonly concurrencyKey: string,\n public readonly incumbent: JobRun,\n ) {\n super(\n `Job type '${jobType}' has an in-flight run with concurrency_key ` +\n `'${concurrencyKey}' (incumbent ${incumbent.id}); collision_mode=reject.`,\n );\n }\n}\n\n/**\n * `replay` was called on a run that is not in a replayable terminal state\n * (i.e. still `pending` / `running` / `waiting`). Replay always spawns\n * fresh execution and therefore requires the source run to be settled.\n */\nexport class JobNotReplayableError extends Error {\n override readonly name = 'JobNotReplayableError';\n constructor(\n public readonly runId: string,\n public readonly currentStatus: string,\n ) {\n super(\n `Run ${runId} is not replayable from status '${currentStatus}'. ` +\n `Only 'completed', 'failed', 'timed_out', and 'canceled' are eligible.`,\n );\n }\n}\n\n/**\n * A `concurrency_key_template` or `dedupe_key_template` referenced a field\n * that is not present on the input payload. Caught at `start` time so the\n * caller sees the misconfiguration synchronously rather than at claim time.\n */\nexport class JobTemplateFieldMissingError extends Error {\n override readonly name = 'JobTemplateFieldMissingError';\n constructor(\n public readonly template: string,\n public readonly field: string,\n ) {\n super(\n `Template '${template}' references input field '${field}' which is ` +\n `missing or undefined on the payload.`,\n );\n }\n}\n\n/**\n * Thrown by the four multi-tenant-aware service-layer backends (JOB-8)\n * when `JobsDomainModule` was configured with `multiTenant: true` but the\n * caller did not pass a `tenantId` in the relevant options object.\n *\n * **Strict enforcement rationale (resolved 2026-04-18).** Cross-tenant data\n * leakage is the worst class of bug a multi-tenant system can ship; surfacing\n * the misuse loudly at the call site (rather than silently defaulting to\n * `null` or to the \"last tenant seen\") prevents both accidental global\n * writes and sneaky reads that return a union of tenants.\n *\n * - `undefined` `tenantId` → throw this error.\n * - Explicit `null` `tenantId` → passes; opts the call into cross-tenant\n * background work (e.g. a nightly housekeeping job that must scan all\n * tenants). The row is persisted with `tenant_id = NULL`.\n */\nexport class MissingTenantIdError extends Error {\n override readonly name = 'MissingTenantIdError';\n constructor(public readonly method: string) {\n super(\n `MissingTenantIdError: JobsDomainModule was configured with ` +\n `multiTenant=true but ${method} was called without tenantId ` +\n `(undefined). Pass an explicit tenantId, or pass null for ` +\n `cross-tenant work.`,\n );\n }\n}\n\n/**\n * Thrown by `JobWorkerModule.onModuleInit` (Drizzle backend only) when the\n * `job` table contains type rows for which no `@JobHandler` is registered\n * in the running process. Surfaces every orphaned type at once so a single\n * boot tells the operator everything to clean up.\n *\n * Skipped entirely in memory mode (Q4 resolution 2026-04-19) — the memory\n * backend has no DB rows to validate; `MemoryJobOrchestrator.start()`\n * throws `JobTypeNotFoundError` synchronously for unknown types instead.\n */\nexport class BootValidationError extends Error {\n override readonly name = 'BootValidationError';\n constructor(public readonly missingHandlers: string[]) {\n super(\n `BootValidationError: ${missingHandlers.length} orphaned job type(s) ` +\n `in 'job' table with no matching @JobHandler in the running process: ` +\n `[${missingHandlers.join(', ')}]. Either register the handler(s) or ` +\n `remove the rows.`,\n );\n }\n}\n\n/**\n * Thrown by `JobWorkerModule.onModuleInit` when one or more `@JobHandler`\n * classes target a `reserved: true` pool from the resolved pool config\n * (the three `events_*` pools are reserved for the events subsystem\n * outbox drain). Listing every offender on a single boot avoids the\n * fix-one-restart-fix-next loop.\n */\nexport class ReservedPoolViolationError extends Error {\n override readonly name = 'ReservedPoolViolationError';\n constructor(\n public readonly offenders: ReadonlyArray<{\n handlerClass: string;\n pool: string;\n }>,\n ) {\n super(\n `ReservedPoolViolationError: ${offenders.length} @JobHandler(s) target ` +\n `reserved pools — reserved pools are framework-only:\\n` +\n offenders\n .map((o) => ` - ${o.handlerClass} → pool='${o.pool}'`)\n .join('\\n'),\n );\n }\n}\n"],"mappings":"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","names":[]}
@@ -1,4 +1,4 @@
1
- import { i as JobRun } from '../../../job-orchestrator.protocol-BwsBd37o.js';
1
+ import { i as JobRun } from '../../../job-orchestrator.protocol-CHOEqBDk.js';
2
2
  import '../events/event-bus.protocol.js';
3
3
  import '../../types/drizzle.js';
4
4
  import 'drizzle-orm/node-postgres';
@@ -6,6 +6,7 @@ import './job-orchestration.schema.js';
6
6
  import 'drizzle-orm/pg-core';
7
7
  import 'drizzle-orm';
8
8
  import '@nestjs/common';
9
+ import '../events/generated/types.js';
9
10
 
10
11
  /**
11
12
  * IJobRunService — scope-oriented queries and bulk operations over
@@ -53,6 +54,72 @@ interface PoolStatusCount {
53
54
  status: JobRun['status'];
54
55
  count: number;
55
56
  }
57
+ /**
58
+ * Filter + keyset-pagination input for `IJobRunService.listJobRuns`
59
+ * (OBS-LIST-1). The combiner's `listJobRuns` forwards this verbatim.
60
+ *
61
+ * Pagination is keyset (a.k.a. seek) on `created_at` descending: pass the
62
+ * previous page's `nextCursor` as `cursor` to fetch the following page.
63
+ * Keyset (not offset) so deep pages stay O(log n) and don't drift as new
64
+ * rows arrive at the head.
65
+ */
66
+ interface ListJobRunsQuery {
67
+ /** Filter to a single `pool`. */
68
+ poolId?: string;
69
+ /**
70
+ * Filter to a single run tree by `root_run_id`. Used by the correlation
71
+ * timeline to gather every run sharing a root.
72
+ */
73
+ rootRunId?: string;
74
+ /** Filter to a single status. Accepts any `JobRun['status']`. */
75
+ status?: JobRun['status'];
76
+ /** Lower bound on `created_at` (inclusive). */
77
+ since?: Date;
78
+ /**
79
+ * Opaque keyset cursor returned as `nextCursor` from a previous page.
80
+ * Encodes the `(createdAt, id)` of the last row seen.
81
+ */
82
+ cursor?: string;
83
+ /** Page size. Backend clamps to a sane default + max. */
84
+ limit?: number;
85
+ /**
86
+ * Multi-tenancy gate, same semantics as `countByPoolAndStatus`:
87
+ * - `multiTenant` off → ignored.
88
+ * - on + string → filters `tenant_id = :tenantId`.
89
+ * - on + null → filters `tenant_id IS NULL`.
90
+ * - on + undefined → throws `MissingTenantIdError`.
91
+ */
92
+ tenantId?: string | null;
93
+ }
94
+ /**
95
+ * Summary row for the `job_run` list (OBS-LIST-1). A narrow projection over
96
+ * `JobRun` carrying the columns a runs viewer renders. `rootRunId` is
97
+ * included so the correlation timeline can stitch runs to events.
98
+ */
99
+ interface JobRunSummary {
100
+ runId: string;
101
+ rootRunId: string;
102
+ jobType: string;
103
+ pool: string;
104
+ status: JobRun['status'];
105
+ scopeEntityType: string | null;
106
+ scopeEntityId: string | null;
107
+ tenantId: string | null;
108
+ attempts: number;
109
+ errorMessage: string | null;
110
+ runAt: Date;
111
+ startedAt: Date | null;
112
+ finishedAt: Date | null;
113
+ createdAt: Date;
114
+ }
115
+ /**
116
+ * One page of `listJobRuns` results. `nextCursor` is `null` when there are
117
+ * no more rows; otherwise pass it back as `query.cursor` for the next page.
118
+ */
119
+ interface JobRunPage {
120
+ items: JobRunSummary[];
121
+ nextCursor: string | null;
122
+ }
56
123
  /**
57
124
  * Summary row for the "recent failed runs" observability widget (OBS-2). A
58
125
  * narrow projection over `JobRun` — just the fields a dashboard needs.
@@ -100,6 +167,13 @@ interface IJobRunService {
100
167
  * Tenant gate follows `countByPoolAndStatus`.
101
168
  */
102
169
  listRecentFailed(limit: number, tenantId?: string | null): Promise<JobRunFailure[]>;
170
+ /**
171
+ * Paginated, filterable list of `job_run` rows for the observability runs
172
+ * viewer (OBS-LIST-1). Newest first (`created_at` desc, `id` desc as the
173
+ * keyset tie-break). Returns a `JobRunPage` with an opaque `nextCursor`
174
+ * for keyset pagination. Tenant gate follows `countByPoolAndStatus`.
175
+ */
176
+ listJobRuns(query?: ListJobRunsQuery): Promise<JobRunPage>;
103
177
  }
104
178
 
105
- export type { CancelForScopeOptions, IJobRunService, JobRunFailure, ListForScopeOptions, PoolStatusCount, RescheduleForScopeOptions };
179
+ export type { CancelForScopeOptions, IJobRunService, JobRunFailure, JobRunPage, JobRunSummary, ListForScopeOptions, ListJobRunsQuery, PoolStatusCount, RescheduleForScopeOptions };
@@ -0,0 +1,49 @@
1
+ import { ModuleRef } from '@nestjs/core';
2
+ import { ConnectionOptions } from 'bullmq';
3
+ import { DrizzleClient } from '../../types/drizzle.js';
4
+ import { I as IJobOrchestrator } from '../../../job-orchestrator.protocol-CHOEqBDk.js';
5
+ import { IJobStepService } from './job-step-service.protocol.js';
6
+ import 'drizzle-orm/node-postgres';
7
+ import '../events/event-bus.protocol.js';
8
+ import './job-orchestration.schema.js';
9
+ import 'drizzle-orm/pg-core';
10
+ import 'drizzle-orm';
11
+ import '@nestjs/common';
12
+ import '../events/generated/types.js';
13
+
14
+ /**
15
+ * Options for a single per-pool BullMQ worker.
16
+ */
17
+ interface BullMQJobWorkerOptions {
18
+ /** Logical pool name (matches `job_run.pool`). */
19
+ pool: string;
20
+ /** Fully-resolved BullMQ queue name to consume. */
21
+ queueName: string;
22
+ /** Max concurrent in-flight processors. */
23
+ concurrency: number;
24
+ /** ioredis-compatible connection. */
25
+ connection: ConnectionOptions;
26
+ }
27
+ declare class BullMQJobWorker {
28
+ private readonly db;
29
+ private readonly orchestrator;
30
+ private readonly stepService;
31
+ private readonly options;
32
+ private readonly moduleRef;
33
+ private readonly logger;
34
+ private worker;
35
+ constructor(db: DrizzleClient, orchestrator: IJobOrchestrator, stepService: IJobStepService, options: BullMQJobWorkerOptions, moduleRef: ModuleRef);
36
+ onModuleInit(): Promise<void>;
37
+ onModuleDestroy(): Promise<void>;
38
+ /**
39
+ * Process one BullMQ job. Returns the handler output (stored by BullMQ as
40
+ * the job return value AND written to `job_run.output`). Throws on handler
41
+ * failure so BullMQ applies the retry policy.
42
+ */
43
+ private process;
44
+ private markFailed;
45
+ private makeStepFn;
46
+ private makeSpawnFn;
47
+ }
48
+
49
+ export { BullMQJobWorker, type BullMQJobWorkerOptions };
@@ -0,0 +1,374 @@
1
+ // runtime/subsystems/jobs/job-worker.bullmq-backend.ts
2
+ import { Logger } from "@nestjs/common";
3
+ import { eq } from "drizzle-orm";
4
+
5
+ // runtime/subsystems/jobs/job-orchestration.schema.ts
6
+ import {
7
+ pgEnum,
8
+ pgTable,
9
+ uuid,
10
+ text,
11
+ jsonb,
12
+ integer,
13
+ timestamp,
14
+ index,
15
+ uniqueIndex
16
+ } from "drizzle-orm/pg-core";
17
+ import { sql } from "drizzle-orm";
18
+ var jobRunStatusEnum = pgEnum("job_run_status", [
19
+ "pending",
20
+ "running",
21
+ "waiting",
22
+ "completed",
23
+ "failed",
24
+ "timed_out",
25
+ "canceled"
26
+ ]);
27
+ var jobStepKindEnum = pgEnum("job_step_kind", ["task"]);
28
+ var jobStepStatusEnum = pgEnum("job_step_status", [
29
+ "pending",
30
+ "running",
31
+ "completed",
32
+ "failed",
33
+ "skipped"
34
+ ]);
35
+ var collisionModeEnum = pgEnum("job_collision_mode", [
36
+ "queue",
37
+ "reject",
38
+ "replace"
39
+ ]);
40
+ var replayFromEnum = pgEnum("job_replay_from", [
41
+ "scratch",
42
+ "last_step",
43
+ "last_checkpoint"
44
+ ]);
45
+ var parentClosePolicyEnum = pgEnum("job_parent_close_policy", [
46
+ "terminate",
47
+ "cancel",
48
+ "abandon"
49
+ ]);
50
+ var waitKindEnum = pgEnum("job_wait_kind", ["signal"]);
51
+ var triggerSourceEnum = pgEnum("job_trigger_source", [
52
+ "manual",
53
+ "schedule",
54
+ "event",
55
+ "parent"
56
+ ]);
57
+ var jobs = pgTable("job", {
58
+ type: text("type").primaryKey(),
59
+ version: integer("version").notNull().default(1),
60
+ pool: text("pool").notNull(),
61
+ scopeEntityType: text("scope_entity_type"),
62
+ retryPolicy: jsonb("retry_policy").notNull().$type(),
63
+ timeoutMs: integer("timeout_ms"),
64
+ concurrencyKeyTemplate: text("concurrency_key_template"),
65
+ collisionMode: collisionModeEnum("collision_mode").notNull().default("queue"),
66
+ dedupeKeyTemplate: text("dedupe_key_template"),
67
+ dedupeWindowMs: integer("dedupe_window_ms"),
68
+ priorityDefault: integer("priority_default").notNull().default(0),
69
+ replayFrom: replayFromEnum("replay_from").notNull().default("last_checkpoint"),
70
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
71
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
72
+ });
73
+ var jobRuns = pgTable(
74
+ "job_run",
75
+ {
76
+ id: uuid("id").primaryKey().defaultRandom(),
77
+ jobType: text("job_type").notNull().references(() => jobs.type),
78
+ jobVersion: integer("job_version").notNull(),
79
+ parentRunId: uuid("parent_run_id").references(() => jobRuns.id),
80
+ /**
81
+ * Service generates `id` client-side via randomUUID() and sets
82
+ * root_run_id = id for root runs (single INSERT, no self-FK race).
83
+ */
84
+ rootRunId: uuid("root_run_id").notNull(),
85
+ parentClosePolicy: parentClosePolicyEnum("parent_close_policy").notNull().default("terminate"),
86
+ scopeEntityType: text("scope_entity_type"),
87
+ scopeEntityId: text("scope_entity_id"),
88
+ tenantId: text("tenant_id"),
89
+ tags: jsonb("tags").notNull().default({}).$type(),
90
+ pool: text("pool").notNull(),
91
+ priority: integer("priority").notNull().default(0),
92
+ concurrencyKey: text("concurrency_key"),
93
+ dedupeKey: text("dedupe_key"),
94
+ status: jobRunStatusEnum("status").notNull().default("pending"),
95
+ input: jsonb("input").notNull().$type(),
96
+ output: jsonb("output").$type(),
97
+ error: jsonb("error").$type(),
98
+ triggerSource: triggerSourceEnum("trigger_source").notNull(),
99
+ triggerRef: text("trigger_ref"),
100
+ runAt: timestamp("run_at", { withTimezone: true }).notNull().defaultNow(),
101
+ startedAt: timestamp("started_at", { withTimezone: true }),
102
+ finishedAt: timestamp("finished_at", { withTimezone: true }),
103
+ claimedAt: timestamp("claimed_at", { withTimezone: true }),
104
+ attempts: integer("attempts").notNull().default(0),
105
+ // Phase 3 placeholder — see ADR-025
106
+ waitKind: waitKindEnum("wait_kind"),
107
+ // Phase 3 placeholder — see ADR-025
108
+ resumeToken: text("resume_token"),
109
+ // Phase 3 placeholder — see ADR-025
110
+ waitDeadline: timestamp("wait_deadline", { withTimezone: true }),
111
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
112
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
113
+ },
114
+ (t) => ({
115
+ /** Claim query: ORDER BY priority DESC, run_at ASC. */
116
+ idxJobRunClaim: index("idx_job_run_claim").on(t.status, t.pool, t.runAt),
117
+ /** Tree traversal / cascade cancel. */
118
+ idxJobRunRoot: index("idx_job_run_root").on(t.rootRunId),
119
+ /** listForScope query. */
120
+ idxJobRunScope: index("idx_job_run_scope").on(t.scopeEntityType, t.scopeEntityId),
121
+ /** Idempotency collapse — partial index. */
122
+ idxJobRunDedupe: index("idx_job_run_dedupe").on(t.jobType, t.dedupeKey).where(sql`${t.dedupeKey} IS NOT NULL`),
123
+ /** Collision check — partial index. */
124
+ idxJobRunConcurrency: index("idx_job_run_concurrency").on(t.concurrencyKey).where(
125
+ sql`${t.concurrencyKey} IS NOT NULL AND ${t.status} IN ('pending','running')`
126
+ )
127
+ })
128
+ );
129
+ var jobSteps = pgTable(
130
+ "job_step",
131
+ {
132
+ id: uuid("id").primaryKey().defaultRandom(),
133
+ jobRunId: uuid("job_run_id").notNull().references(() => jobRuns.id),
134
+ stepId: text("step_id").notNull(),
135
+ kind: jobStepKindEnum("kind").notNull().default("task"),
136
+ /**
137
+ * Monotonic within run. integer (max ~2B per run) is sufficient —
138
+ * downgraded from ADR-022's bigint; revisit only if a single run
139
+ * ever exceeds 2 billion steps.
140
+ */
141
+ seq: integer("seq").notNull(),
142
+ status: jobStepStatusEnum("status").notNull().default("pending"),
143
+ input: jsonb("input").$type(),
144
+ /** Memoised on success for replay. */
145
+ output: jsonb("output").$type(),
146
+ error: jsonb("error").$type(),
147
+ attempts: integer("attempts").notNull().default(0),
148
+ startedAt: timestamp("started_at", { withTimezone: true }),
149
+ finishedAt: timestamp("finished_at", { withTimezone: true })
150
+ },
151
+ (t) => ({
152
+ /** No duplicate step IDs per run. */
153
+ idxJobStepRunStep: uniqueIndex("idx_job_step_run_step").on(t.jobRunId, t.stepId),
154
+ /** Ordered timeline reads. */
155
+ idxJobStepTimeline: index("idx_job_step_timeline").on(t.jobRunId, t.seq)
156
+ })
157
+ );
158
+
159
+ // runtime/subsystems/jobs/job-handler.base.ts
160
+ var JOB_HANDLER_REGISTRY = /* @__PURE__ */ new Map();
161
+ var HandlerRegistry;
162
+ ((HandlerRegistry2) => {
163
+ function getAll() {
164
+ return Array.from(JOB_HANDLER_REGISTRY.values());
165
+ }
166
+ HandlerRegistry2.getAll = getAll;
167
+ function get(type) {
168
+ return JOB_HANDLER_REGISTRY.get(type);
169
+ }
170
+ HandlerRegistry2.get = get;
171
+ })(HandlerRegistry || (HandlerRegistry = {}));
172
+
173
+ // runtime/subsystems/jobs/job-worker.bullmq-backend.ts
174
+ function serialiseError(err, attempt, retryable) {
175
+ const e = err;
176
+ return {
177
+ message: e?.message ?? String(err),
178
+ stack: e?.stack,
179
+ retryable,
180
+ attempt
181
+ };
182
+ }
183
+ var BullMQJobWorker = class _BullMQJobWorker {
184
+ constructor(db, orchestrator, stepService, options, moduleRef) {
185
+ this.db = db;
186
+ this.orchestrator = orchestrator;
187
+ this.stepService = stepService;
188
+ this.options = options;
189
+ this.moduleRef = moduleRef;
190
+ }
191
+ db;
192
+ orchestrator;
193
+ stepService;
194
+ options;
195
+ moduleRef;
196
+ logger = new Logger(_BullMQJobWorker.name);
197
+ worker = null;
198
+ async onModuleInit() {
199
+ let WorkerCtor;
200
+ try {
201
+ const mod = await import("bullmq");
202
+ WorkerCtor = mod.Worker;
203
+ } catch {
204
+ throw new Error(
205
+ 'BullMQ backend requires the "bullmq" package. Install it with: npm install bullmq'
206
+ );
207
+ }
208
+ this.worker = new WorkerCtor(
209
+ this.options.queueName,
210
+ (job) => this.process(job),
211
+ {
212
+ connection: this.options.connection,
213
+ concurrency: this.options.concurrency
214
+ }
215
+ );
216
+ this.worker.on("failed", (job, err) => {
217
+ if (!job) return;
218
+ const attemptsMade = job.attemptsMade;
219
+ const maxAttempts = job.opts.attempts ?? 1;
220
+ if (attemptsMade >= maxAttempts) {
221
+ void this.markFailed(job.data.runId, err, attemptsMade);
222
+ }
223
+ });
224
+ this.logger.log(
225
+ `BullMQ worker started: pool='${this.options.pool}' queue='${this.options.queueName}' concurrency=${this.options.concurrency}`
226
+ );
227
+ }
228
+ async onModuleDestroy() {
229
+ if (this.worker) {
230
+ await this.worker.close();
231
+ this.worker = null;
232
+ }
233
+ }
234
+ /**
235
+ * Process one BullMQ job. Returns the handler output (stored by BullMQ as
236
+ * the job return value AND written to `job_run.output`). Throws on handler
237
+ * failure so BullMQ applies the retry policy.
238
+ */
239
+ async process(job) {
240
+ const { runId } = job.data;
241
+ const [row] = await this.db.select().from(jobRuns).where(eq(jobRuns.id, runId)).limit(1);
242
+ if (!row) {
243
+ this.logger.warn(`process: job_run ${runId} not found; skipping`);
244
+ return {};
245
+ }
246
+ const run = row;
247
+ if (run.status === "canceled") {
248
+ return {};
249
+ }
250
+ const registryEntry = JOB_HANDLER_REGISTRY.get(run.jobType);
251
+ if (!registryEntry) {
252
+ throw new Error(
253
+ `No handler registered for jobType='${run.jobType}' (run ${run.id})`
254
+ );
255
+ }
256
+ await this.db.update(jobRuns).set({
257
+ status: "running",
258
+ claimedAt: /* @__PURE__ */ new Date(),
259
+ startedAt: /* @__PURE__ */ new Date(),
260
+ attempts: job.attemptsMade + 1,
261
+ updatedAt: /* @__PURE__ */ new Date()
262
+ }).where(eq(jobRuns.id, run.id));
263
+ const HandlerClass = registryEntry.handlerClass;
264
+ const handler = this.moduleRef.get(
265
+ HandlerClass,
266
+ { strict: false }
267
+ );
268
+ const ctx = {
269
+ input: run.input,
270
+ run,
271
+ step: this.makeStepFn(run),
272
+ spawnChild: this.makeSpawnFn(run),
273
+ logger: new Logger(`JobRun:${run.id}`)
274
+ };
275
+ const output = await handler.run(ctx);
276
+ await this.db.update(jobRuns).set({
277
+ status: "completed",
278
+ output: output ?? {},
279
+ finishedAt: /* @__PURE__ */ new Date(),
280
+ updatedAt: /* @__PURE__ */ new Date()
281
+ }).where(eq(jobRuns.id, run.id));
282
+ return output ?? {};
283
+ }
284
+ async markFailed(runId, err, finalAttempts) {
285
+ const [row] = await this.db.select().from(jobRuns).where(eq(jobRuns.id, runId)).limit(1);
286
+ if (!row) return;
287
+ const run = row;
288
+ await this.db.update(jobRuns).set({
289
+ status: "failed",
290
+ attempts: finalAttempts,
291
+ finishedAt: /* @__PURE__ */ new Date(),
292
+ error: serialiseError(err, finalAttempts, false),
293
+ updatedAt: /* @__PURE__ */ new Date()
294
+ }).where(eq(jobRuns.id, runId));
295
+ if (run.parentClosePolicy === "terminate") {
296
+ try {
297
+ await this.orchestrator.cancel(run.id, {
298
+ cascade: true,
299
+ reason: "parent-failed",
300
+ tenantId: run.tenantId
301
+ });
302
+ } catch (cascadeErr) {
303
+ this.logger.warn(
304
+ `cascade on failed run ${run.id}: ${cascadeErr.message}`
305
+ );
306
+ }
307
+ }
308
+ }
309
+ // ── ctx.step / ctx.spawnChild (mirror JobWorker) ──────────────────────────
310
+ makeStepFn(run) {
311
+ return async (stepId, fn, _opts) => {
312
+ void _opts;
313
+ const existing = await this.stepService.findStep(run.id, stepId);
314
+ if (existing?.status === "completed") {
315
+ return existing.output;
316
+ }
317
+ const nextAttempts = (existing?.attempts ?? 0) + 1;
318
+ const seq = nextAttempts;
319
+ await this.stepService.recordStep({
320
+ jobRunId: run.id,
321
+ stepId,
322
+ kind: "task",
323
+ seq,
324
+ status: "running",
325
+ startedAt: /* @__PURE__ */ new Date(),
326
+ attempts: nextAttempts
327
+ });
328
+ try {
329
+ const output = await fn();
330
+ await this.stepService.recordStep({
331
+ jobRunId: run.id,
332
+ stepId,
333
+ kind: "task",
334
+ seq,
335
+ status: "completed",
336
+ output,
337
+ finishedAt: /* @__PURE__ */ new Date(),
338
+ attempts: nextAttempts
339
+ });
340
+ return output;
341
+ } catch (err) {
342
+ await this.stepService.recordStep({
343
+ jobRunId: run.id,
344
+ stepId,
345
+ kind: "task",
346
+ seq,
347
+ status: "failed",
348
+ error: serialiseError(err, nextAttempts, false),
349
+ finishedAt: /* @__PURE__ */ new Date(),
350
+ attempts: nextAttempts
351
+ });
352
+ throw err;
353
+ }
354
+ };
355
+ }
356
+ makeSpawnFn(run) {
357
+ return async (type, input, opts) => {
358
+ return this.orchestrator.start(type, input, {
359
+ parentRunId: run.id,
360
+ parentClosePolicy: opts?.closePolicy,
361
+ runAt: opts?.runAt,
362
+ priority: opts?.priority,
363
+ tags: opts?.tags,
364
+ triggerSource: "parent",
365
+ triggerRef: run.id,
366
+ tenantId: run.tenantId
367
+ });
368
+ };
369
+ }
370
+ };
371
+ export {
372
+ BullMQJobWorker
373
+ };
374
+ //# sourceMappingURL=job-worker.bullmq-backend.js.map