@pattern-stack/codegen 0.21.0 → 0.23.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 (66) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +5 -1
  3. package/dist/{chunk-3A34R6CI.js → chunk-3VEVGL74.js} +4 -4
  4. package/dist/{chunk-G3IKPDTP.js → chunk-42763UEE.js} +2 -2
  5. package/dist/{chunk-524YKITE.js → chunk-4M66MQYA.js} +50 -6
  6. package/dist/chunk-4M66MQYA.js.map +1 -0
  7. package/dist/{chunk-EEJC66ZF.js → chunk-6XP2Q5SS.js} +3 -3
  8. package/dist/{chunk-6CJRZHV4.js → chunk-7B7MMDOJ.js} +57 -4
  9. package/dist/chunk-7B7MMDOJ.js.map +1 -0
  10. package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
  11. package/dist/{chunk-7625PLY7.js → chunk-COGHTKXY.js} +4 -4
  12. package/dist/{chunk-GV337QP3.js → chunk-E5FJWOMP.js} +7 -7
  13. package/dist/{chunk-TKU6VYG3.js → chunk-E6PLM6QG.js} +6 -6
  14. package/dist/{chunk-GMRTI7AK.js → chunk-FIUC6QB5.js} +3 -3
  15. package/dist/chunk-FIUC6QB5.js.map +1 -0
  16. package/dist/{chunk-YXI7K4MJ.js → chunk-PNCOUFFI.js} +7 -5
  17. package/dist/chunk-PNCOUFFI.js.map +1 -0
  18. package/dist/{chunk-MBFSG4KQ.js → chunk-SH76CFAY.js} +9 -4
  19. package/dist/chunk-SH76CFAY.js.map +1 -0
  20. package/dist/runtime/subsystems/auth/auth.module.js +3 -3
  21. package/dist/runtime/subsystems/auth/index.js +7 -7
  22. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +1 -1
  23. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +4 -4
  24. package/dist/runtime/subsystems/bridge/bridge.module.js +10 -10
  25. package/dist/runtime/subsystems/bridge/index.js +13 -13
  26. package/dist/runtime/subsystems/cache/cache.module.js +2 -2
  27. package/dist/runtime/subsystems/cache/index.js +4 -4
  28. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +2 -2
  29. package/dist/runtime/subsystems/events/events.module.js +3 -3
  30. package/dist/runtime/subsystems/events/index.js +5 -5
  31. package/dist/runtime/subsystems/index.js +48 -48
  32. package/dist/runtime/subsystems/jobs/index.js +18 -18
  33. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +5 -5
  34. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +2 -2
  35. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
  36. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
  37. package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
  38. package/dist/runtime/subsystems/jobs/job-worker.js +4 -2
  39. package/dist/runtime/subsystems/jobs/job-worker.module.js +6 -6
  40. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +19 -0
  41. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +4 -4
  42. package/dist/runtime/subsystems/storage/index.js +1 -1
  43. package/dist/runtime/subsystems/storage/storage.module.js +1 -1
  44. package/dist/src/cli/index.js +226 -65
  45. package/dist/src/cli/index.js.map +1 -1
  46. package/dist/src/index.d.ts +477 -1
  47. package/dist/src/index.js +1 -1
  48. package/package.json +1 -1
  49. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
  50. package/runtime/subsystems/jobs/job-worker.module.ts +5 -0
  51. package/runtime/subsystems/jobs/job-worker.ts +126 -12
  52. package/runtime/subsystems/jobs/jobs-domain.module.ts +19 -0
  53. package/templates/entity/new/clean-lite-ps/prompt-extension.js +59 -10
  54. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +11 -0
  55. package/dist/chunk-524YKITE.js.map +0 -1
  56. package/dist/chunk-6CJRZHV4.js.map +0 -1
  57. package/dist/chunk-GMRTI7AK.js.map +0 -1
  58. package/dist/chunk-MBFSG4KQ.js.map +0 -1
  59. package/dist/chunk-YXI7K4MJ.js.map +0 -1
  60. /package/dist/{chunk-3A34R6CI.js.map → chunk-3VEVGL74.js.map} +0 -0
  61. /package/dist/{chunk-G3IKPDTP.js.map → chunk-42763UEE.js.map} +0 -0
  62. /package/dist/{chunk-EEJC66ZF.js.map → chunk-6XP2Q5SS.js.map} +0 -0
  63. /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
  64. /package/dist/{chunk-7625PLY7.js.map → chunk-COGHTKXY.js.map} +0 -0
  65. /package/dist/{chunk-GV337QP3.js.map → chunk-E5FJWOMP.js.map} +0 -0
  66. /package/dist/{chunk-TKU6VYG3.js.map → chunk-E6PLM6QG.js.map} +0 -0
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/job-worker.ts"],"sourcesContent":["/**\n * JobWorker — backend-agnostic tick loop for the job orchestration domain\n * (ADR-022, JOB-3).\n *\n * One worker instance per active pool. On `onModuleInit` it starts two\n * intervals: the poll loop (claim → process → repeat) and the stale-claim\n * sweeper. On `onModuleDestroy` / SIGTERM it drains in-flight work and\n * releases still-`running` rows back to `pending` so a replacement worker\n * can resume with step memoization intact.\n *\n * The claim query is the beating heart: `SELECT … FOR UPDATE SKIP LOCKED`\n * inside a single transaction. Multiple worker processes share the table\n * without serialising on row locks.\n */\n// TODO(logging-subsystem): swap to ILogger once ADR-028 lands\nimport { Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';\nimport { ModuleRef } from '@nestjs/core';\nimport { and, asc, desc, eq, inArray, lt, lte, sql } from 'drizzle-orm';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { tokenKey } from '../token-key';\nimport { DRIZZLE } from '../../constants/tokens';\nimport { jobRuns, type JobRunRow } from './job-orchestration.schema';\nimport type { IJobOrchestrator, JobRun } from './job-orchestrator.protocol';\nimport type { IJobRunService } from './job-run-service.protocol';\nimport type { IJobStepService } from './job-step-service.protocol';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n} from './jobs-domain.tokens';\nimport {\n JOB_HANDLER_REGISTRY,\n JobHandlerBase,\n type JobContext,\n type JobHandlerMeta,\n type RetryPolicy,\n type SpawnChildOptions,\n type StepOptions,\n} from './job-handler.base';\nimport { JOBS_WAKE_CHANNEL, PgNotifyListener } from './pg-notify';\n\n/**\n * Options accepted by `JobWorker`. JOB-5 threads these through module\n * `.forRoot()` config; supplied here as a plain DI-constructor argument\n * so the worker compiles standalone.\n */\nexport interface JobWorkerOptions {\n /** Pool name this worker claims from. Matches `job.pool`. */\n pool: string;\n /** Max concurrent in-flight `processRun` calls. */\n concurrency: number;\n /** Poll interval in ms. Default 1000. */\n pollIntervalMs?: number;\n /** Stale sweep interval in ms. Default 60_000. */\n staleSweeperIntervalMs?: number;\n /**\n * Threshold beyond which a `running` row is presumed stranded by a\n * crashed worker. Default 5 min. Must be >= 2× max handler duration.\n */\n staleThresholdMs?: number;\n /** Max ms to wait for in-flight drain on SIGTERM. Default 30_000. */\n shutdownTimeoutMs?: number;\n /**\n * LISTEN-NOTIFY-1 — when true, hold a dedicated listener connection and\n * LISTEN on `codegen_jobs_wake`. A notification naming this worker's `pool`\n * triggers an immediate (debounced) claim cycle, so an enqueue is claimed in\n * milliseconds instead of waiting for the next `pollIntervalMs` tick. Polling\n * continues unchanged as the fallback heartbeat. Default false.\n */\n listenNotify?: boolean;\n}\n\n// ADR-037: namespaced `Symbol.for(...)` (via `tokenKey()`) — matches by value\n// across runtime copies.\nexport const JOB_WORKER_OPTIONS = Symbol.for(tokenKey('jobs', 'worker-options'));\n\nconst DEFAULT_POLL_INTERVAL_MS = 1_000;\nconst DEFAULT_STALE_SWEEPER_INTERVAL_MS = 60_000;\nconst DEFAULT_STALE_THRESHOLD_MS = 5 * 60_000;\nconst DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;\n\nconst TERMINAL_STATUSES: JobRunRow['status'][] = [\n 'completed',\n 'failed',\n 'timed_out',\n 'canceled',\n];\n\n// ─── Pure helpers (exported for unit tests) ────────────────────────────────\n\n/**\n * Backoff delay in ms for the Nth attempt (1-indexed). Supports both\n * policy modes. Exponential is capped at `Number.MAX_SAFE_INTEGER` so\n * pathological attempt counts don't overflow.\n */\nexport function computeBackoff(policy: RetryPolicy, attempts: number): number {\n const base = Math.max(policy.baseMs, 0);\n if (policy.backoff === 'fixed') {\n return base;\n }\n // exponential: baseMs * 2^(attempts-1)\n const exponent = Math.max(attempts - 1, 0);\n if (exponent >= 53) return Number.MAX_SAFE_INTEGER; // 2^53 overflow guard\n const raw = base * Math.pow(2, exponent);\n if (!Number.isFinite(raw) || raw >= Number.MAX_SAFE_INTEGER) {\n return Number.MAX_SAFE_INTEGER;\n }\n return raw;\n}\n\n/**\n * Decide whether an error should be retried under the given policy.\n * Matches `nonRetryableErrors` by `.name` OR `.code`. Returns\n * - `'retry'` if attempts remain and the error isn't blacklisted,\n * - `'fail'` otherwise (terminal failure).\n */\nexport function classifyError(\n err: unknown,\n policy: RetryPolicy | undefined,\n currentAttempts: number,\n): 'retry' | 'fail' {\n if (!policy) return 'fail';\n const errObj = err as { name?: string; code?: string } | undefined;\n const name = errObj?.name;\n const code = errObj?.code;\n const nonRetryable = policy.nonRetryableErrors ?? [];\n if (nonRetryable.some((n) => n === name || n === code)) return 'fail';\n if (currentAttempts + 1 >= policy.attempts) return 'fail';\n return 'retry';\n}\n\n/**\n * Build the raw claim-candidate select. Exported so tests can inspect\n * `.toSQL()` without spinning up the full worker. Matches JOB-3 §4 and\n * ADR-022 \"Claim query (Drizzle backend)\".\n */\nexport function buildClaimQuery(db: DrizzleClient, pool: string) {\n return db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'pending'),\n eq(jobRuns.pool, pool),\n lte(jobRuns.runAt, new Date()),\n ),\n )\n .orderBy(desc(jobRuns.priority), asc(jobRuns.runAt))\n .limit(1)\n .for('update', { skipLocked: true });\n}\n\n/**\n * Build the stale-claim sweep candidate select. `FOR UPDATE SKIP LOCKED`\n * per OQ-2 resolution (2026-04-19): per-worker sweeper, safe without\n * leader election because the update is self-gating.\n */\nexport function buildStaleSweepQuery(\n db: DrizzleClient,\n staleThresholdMs: number,\n) {\n const threshold = new Date(Date.now() - staleThresholdMs);\n return db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'running'),\n lt(jobRuns.claimedAt, threshold),\n ),\n )\n .for('update', { skipLocked: true });\n}\n\n// ─── Error serialisation ───────────────────────────────────────────────────\n\nfunction serialiseError(err: unknown, attempt: number, retryable: boolean) {\n const e = err as { message?: string; stack?: string; code?: string } | undefined;\n return {\n message: (e?.message ?? String(err)) as string,\n stack: e?.stack,\n retryable,\n attempt,\n };\n}\n\n// ─── JobWorker ─────────────────────────────────────────────────────────────\n\n@Injectable()\nexport class JobWorker implements OnModuleInit, OnModuleDestroy {\n private readonly logger = new Logger(JobWorker.name);\n private shuttingDown = false;\n private readonly inFlight = new Set<Promise<void>>();\n private pollTimer: ReturnType<typeof setInterval> | null = null;\n private sweeperTimer: ReturnType<typeof setInterval> | null = null;\n private sigtermHandled = false;\n private readonly sigtermHandler: () => void;\n\n private readonly pollIntervalMs: number;\n private readonly staleSweeperIntervalMs: number;\n private readonly staleThresholdMs: number;\n private readonly shutdownTimeoutMs: number;\n\n // LISTEN-NOTIFY-1 — dedicated listener + debounce state. `null` when\n // `listenNotify` is off (the common case); polling is the only driver then.\n private readonly listenNotifyEnabled: boolean;\n private notifyListener: PgNotifyListener | null = null;\n /** True while a wake-driven claim cycle is in flight (debounce gate). */\n private wakeDraining = false;\n /** A notify arrived mid-cycle → re-check once when the cycle ends. */\n private wakeRecheckPending = false;\n\n constructor(\n @Inject(DRIZZLE) private readonly db: DrizzleClient,\n @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,\n @Inject(JOB_RUN_SERVICE) private readonly runService: IJobRunService,\n @Inject(JOB_STEP_SERVICE) private readonly stepService: IJobStepService,\n @Inject(JOB_WORKER_OPTIONS) private readonly options: JobWorkerOptions,\n private readonly moduleRef: ModuleRef,\n ) {\n this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n this.staleSweeperIntervalMs =\n options.staleSweeperIntervalMs ?? DEFAULT_STALE_SWEEPER_INTERVAL_MS;\n this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;\n this.shutdownTimeoutMs =\n options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;\n this.listenNotifyEnabled = options.listenNotify ?? false;\n\n this.sigtermHandler = () => {\n if (this.sigtermHandled) return;\n this.sigtermHandled = true;\n void this.onModuleDestroy();\n };\n void this.runService; // reserved for future scope-aware cancellation paths\n }\n\n // ============================================================================\n // Lifecycle\n // ============================================================================\n\n onModuleInit(): void {\n this.pollTimer = setInterval(() => {\n void this.pollAndProcess();\n }, this.pollIntervalMs);\n this.sweeperTimer = setInterval(() => {\n void this.sweepStaleClaims();\n }, this.staleSweeperIntervalMs);\n process.on('SIGTERM', this.sigtermHandler);\n\n // LISTEN-NOTIFY-1 — start the wake listener ALONGSIDE the poll timer (never\n // instead). A notify for this worker's pool drives an immediate claim cycle;\n // the interval timer above stays the durability heartbeat. Listener startup\n // is fire-and-forget: a connect failure self-heals via the listener's own\n // backoff, and until it's up the poll loop is the sole driver.\n if (this.listenNotifyEnabled) {\n // The DRIZZLE provider wraps a `pg.Pool`, exposed by drizzle as `$client`.\n const pool = (this.db as unknown as { $client?: unknown }).$client;\n if (!pool || typeof (pool as { connect?: unknown }).connect !== 'function') {\n this.logger.warn(\n `listen_notify enabled but the Drizzle client exposes no pg Pool ` +\n `($client.connect missing) — falling back to interval polling only.`,\n );\n } else {\n this.notifyListener = new PgNotifyListener({\n channel: JOBS_WAKE_CHANNEL,\n pool: pool as { connect(): Promise<never> },\n label: `jobs:${this.options.pool}`,\n onNotify: (payload) => this.onWake(payload),\n });\n void this.notifyListener.start();\n }\n }\n }\n\n /**\n * Wake handler — a `codegen_jobs_wake` notification arrived. Only payloads\n * naming THIS worker's pool are relevant (other pools have their own workers).\n * Debounced: if a claim cycle is already running we just flag a re-check so a\n * burst of N enqueues collapses to at most one extra cycle (D3).\n */\n private onWake(payload: string): void {\n if (this.shuttingDown) return;\n if (payload !== this.options.pool) return;\n if (this.wakeDraining) {\n this.wakeRecheckPending = true;\n return;\n }\n void this.drainOnWake();\n }\n\n /**\n * Claim-until-empty on a wake. Unlike the interval `pollAndProcess` (one\n * claim per tick), a wake drains greedily up to the concurrency ceiling so a\n * burst that arrived together is dispatched without waiting for N ticks. The\n * `wakeRecheckPending` flag coalesces notifies that land mid-drain.\n */\n private async drainOnWake(): Promise<void> {\n this.wakeDraining = true;\n try {\n do {\n this.wakeRecheckPending = false;\n // Claim while there's capacity; pollAndProcess no-ops at the ceiling.\n let progressed = true;\n while (\n progressed &&\n !this.shuttingDown &&\n this.inFlight.size < this.options.concurrency\n ) {\n const before = this.inFlight.size;\n await this.pollAndProcess();\n progressed = this.inFlight.size > before;\n }\n } while (this.wakeRecheckPending && !this.shuttingDown);\n } finally {\n this.wakeDraining = false;\n }\n }\n\n async onModuleDestroy(): Promise<void> {\n // LISTEN-NOTIFY-2 — release the listener connection on EVERY destroy path,\n // including the `shuttingDown` early-return below (reached when SIGTERM and\n // Nest's onModuleDestroy both fire) and a destroy with no prior SIGTERM.\n // Previously this lived only on the first (non-shuttingDown) branch, so a\n // double-fire — or a stop() that raced the listener's own in-flight\n // connect() — left a `LISTEN codegen_jobs_wake` socket open past\n // `app.close()`, hanging the process. `PgNotifyListener.stop()` is itself\n // idempotent + connect-race-safe (LISTEN-NOTIFY-2). Best-effort; a failure\n // here doesn't block the drain.\n await this.stopNotifyListener();\n\n if (this.shuttingDown) {\n // Still drain, but don't tear intervals down twice.\n await this.drainInFlight();\n return;\n }\n this.shuttingDown = true;\n if (this.pollTimer) {\n clearInterval(this.pollTimer);\n this.pollTimer = null;\n }\n if (this.sweeperTimer) {\n clearInterval(this.sweeperTimer);\n this.sweeperTimer = null;\n }\n process.removeListener('SIGTERM', this.sigtermHandler);\n\n await this.drainInFlight();\n\n // Any rows still `running` past timeout → release back to pending.\n try {\n await this.db\n .update(jobRuns)\n .set({ status: 'pending', claimedAt: null, startedAt: null })\n .where(\n and(eq(jobRuns.status, 'running'), eq(jobRuns.pool, this.options.pool)),\n );\n } catch (err) {\n this.logger.error(`shutdown reset failed: ${(err as Error).message}`);\n }\n }\n\n private async drainInFlight(): Promise<void> {\n if (this.inFlight.size === 0) return;\n const timeout = new Promise<void>((resolve) =>\n setTimeout(resolve, this.shutdownTimeoutMs),\n );\n await Promise.race([\n Promise.allSettled([...this.inFlight]).then(() => undefined),\n timeout,\n ]);\n }\n\n /**\n * LISTEN-NOTIFY-2 — stop + drop the wake listener. Idempotent: a second call\n * (SIGTERM + Nest destroy) finds `notifyListener` already null and no-ops.\n * `PgNotifyListener.stop()` is itself race-safe against an in-flight\n * `connect()`, so even a destroy that arrives microseconds after `start()`\n * releases the listener socket rather than leaking it.\n */\n private async stopNotifyListener(): Promise<void> {\n const listener = this.notifyListener;\n if (!listener) return;\n this.notifyListener = null;\n try {\n await listener.stop();\n } catch (err) {\n this.logger.error(`notify listener stop failed: ${(err as Error).message}`);\n }\n }\n\n // ============================================================================\n // Poll loop\n // ============================================================================\n\n async pollAndProcess(): Promise<void> {\n if (this.shuttingDown) return;\n if (this.inFlight.size >= this.options.concurrency) return;\n\n let claimed: JobRunRow | null;\n try {\n claimed = await this.claimNext(this.options.pool);\n } catch (err) {\n this.logger.error(`claimNext failed: ${(err as Error).message}`);\n return;\n }\n if (!claimed) return;\n\n const run = claimed;\n const promise = this.processRun(run).catch((err) => {\n this.logger.error(\n `processRun(${run.id}) unhandled: ${(err as Error).message}`,\n );\n });\n this.inFlight.add(promise);\n promise.finally(() => {\n this.inFlight.delete(promise);\n });\n }\n\n /**\n * Claim the next runnable row from the pool. Transaction ensures the\n * select-candidate + update-to-running pair is atomic; FOR UPDATE SKIP\n * LOCKED lets multiple workers share the table without serialising.\n */\n async claimNext(pool: string): Promise<JobRunRow | null> {\n return this.db.transaction(async (tx) => {\n const candidates = await tx\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'pending'),\n eq(jobRuns.pool, pool),\n lte(jobRuns.runAt, new Date()),\n ),\n )\n .orderBy(desc(jobRuns.priority), asc(jobRuns.runAt))\n .limit(1)\n .for('update', { skipLocked: true });\n const candidate = candidates[0];\n if (!candidate) return null;\n\n const [claimed] = await tx\n .update(jobRuns)\n .set({\n status: 'running',\n claimedAt: new Date(),\n startedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, candidate.id))\n .returning();\n return (claimed ?? null) as JobRunRow | null;\n });\n }\n\n // ============================================================================\n // Stale claim sweeper\n // ============================================================================\n\n /**\n * Release rows whose `claimed_at` is older than the threshold. Safe to\n * run concurrently across workers — the two-phase tx (select-for-update\n * then update) guarantees each stranded row is only reset once.\n */\n async sweepStaleClaims(): Promise<void> {\n if (this.shuttingDown) return;\n try {\n await this.db.transaction(async (tx) => {\n const threshold = new Date(Date.now() - this.staleThresholdMs);\n const stale = await tx\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(eq(jobRuns.status, 'running'), lt(jobRuns.claimedAt, threshold)),\n )\n .for('update', { skipLocked: true });\n if (stale.length === 0) return;\n const ids = stale.map((r) => r.id);\n await tx\n .update(jobRuns)\n .set({ status: 'pending', claimedAt: null, startedAt: null })\n .where(inArray(jobRuns.id, ids));\n for (const id of ids) {\n this.logger.warn(`Recovered stale claim on run ${id}`);\n }\n });\n } catch (err) {\n this.logger.error(`sweepStaleClaims failed: ${(err as Error).message}`);\n }\n }\n\n // ============================================================================\n // processRun\n // ============================================================================\n\n private async processRun(claimed: JobRunRow): Promise<void> {\n const registryEntry = JOB_HANDLER_REGISTRY.get(claimed.jobType);\n\n // (a) Missing handler — defensive; JOB-5 boot validator should have caught.\n if (!registryEntry) {\n this.logger.error(\n `No handler registered for jobType='${claimed.jobType}' (run ${claimed.id})`,\n );\n await this.markFailed(\n claimed,\n new Error(`No handler registered for jobType='${claimed.jobType}'`),\n /*finalAttempts*/ (claimed.attempts ?? 0) + 1,\n );\n return;\n }\n\n // (b) Concurrency-queue release gate — defer if another run with the\n // same key is already `running`.\n if (claimed.concurrencyKey) {\n const inflight = await this.db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.concurrencyKey, claimed.concurrencyKey),\n eq(jobRuns.status, 'running'),\n ),\n );\n const other = inflight.find((r) => r.id !== claimed.id);\n if (other) {\n await this.db\n .update(jobRuns)\n .set({\n status: 'pending',\n claimedAt: null,\n startedAt: null,\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n return;\n }\n }\n\n const meta = registryEntry.meta as JobHandlerMeta<unknown>;\n const HandlerClass = registryEntry.handlerClass;\n\n // (c) Build JobContext. Resolve the handler instance from Nest's DI\n // graph so its `@Inject` constructor params (which may come from\n // any module in the app graph) are satisfied. `moduleRef.create()`\n // would otherwise instantiate a fresh class within JobWorkerModule's\n // scope only — which blows up with \"not a provider of the current\n // module\" for any handler that consumes a service from a peer\n // module (e.g. CrmSyncJob injecting CrmSyncFactory from CrmModule).\n // Consequence: handlers MUST be registered as providers in their\n // owning module (@Injectable + `providers: [HandlerClass]`). The\n // @JobHandler decorator handles registry registration only, not DI.\n // See the jobs skill's handler-authoring.md for the registration\n // rule.\n const handler = this.moduleRef.get(\n HandlerClass as unknown as new (...args: unknown[]) => unknown,\n { strict: false },\n ) as JobHandlerBase<unknown>;\n const ctx: JobContext<unknown> = {\n input: claimed.input,\n run: claimed as JobRun,\n step: this.makeStepFn(claimed),\n spawnChild: this.makeSpawnFn(claimed),\n logger: new Logger(`JobRun:${claimed.id}`),\n };\n\n const attemptsBefore = claimed.attempts ?? 0;\n try {\n // (d) Run the handler.\n const output = (await handler.run(ctx)) as Record<string, unknown> | undefined;\n // (e) Success.\n await this.db\n .update(jobRuns)\n .set({\n status: 'completed',\n output: (output ?? {}) as Record<string, unknown>,\n finishedAt: new Date(),\n updatedAt: new Date(),\n attempts: attemptsBefore + 1,\n })\n .where(eq(jobRuns.id, claimed.id));\n } catch (err) {\n // (f) Error classification + retry/fail.\n const policy = meta.retry;\n const decision = classifyError(err, policy, attemptsBefore);\n const nextAttempts = attemptsBefore + 1;\n if (decision === 'retry' && policy) {\n const delay = computeBackoff(policy, nextAttempts);\n await this.db\n .update(jobRuns)\n .set({\n status: 'pending',\n attempts: nextAttempts,\n runAt: new Date(Date.now() + delay),\n startedAt: null,\n claimedAt: null,\n error: serialiseError(err, nextAttempts, true),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n } else {\n await this.markFailed(claimed, err, nextAttempts);\n }\n }\n }\n\n private async markFailed(\n claimed: JobRunRow,\n err: unknown,\n finalAttempts: number,\n ): Promise<void> {\n await this.db\n .update(jobRuns)\n .set({\n status: 'failed',\n attempts: finalAttempts,\n finishedAt: new Date(),\n error: serialiseError(err, finalAttempts, false),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n\n // Parent-close-policy cascade: if this run has children under the same\n // root_run_id and this run's own parentClosePolicy is 'terminate', cascade.\n if (claimed.parentClosePolicy === 'terminate') {\n try {\n // JOB-8 — thread the run's own tenantId so the orchestrator's\n // multi-tenant gate passes. Without this, every terminate-policy\n // cascade throws MissingTenantIdError under multiTenant=true and\n // the outer catch silently swallows it — children never cancel.\n await this.orchestrator.cancel(claimed.id, {\n cascade: true,\n reason: 'parent-failed',\n tenantId: claimed.tenantId,\n });\n } catch (cascadeErr) {\n // cancel is idempotent; failure here is unusual but not fatal.\n this.logger.warn(\n `cascade on failed run ${claimed.id}: ${(cascadeErr as Error).message}`,\n );\n }\n }\n }\n\n // ============================================================================\n // ctx.step / ctx.spawnChild builders\n // ============================================================================\n\n private makeStepFn(run: JobRunRow) {\n return async <TOutput>(\n stepId: string,\n fn: () => Promise<TOutput>,\n _opts?: StepOptions,\n ): Promise<TOutput> => {\n void _opts;\n const existing = await this.stepService.findStep(run.id, stepId);\n if (existing?.status === 'completed') {\n return existing.output as TOutput;\n }\n\n const seq = await this.nextStepSeq(run.id);\n const startedAt = new Date();\n const nextAttempts = (existing?.attempts ?? 0) + 1;\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'running',\n startedAt,\n attempts: nextAttempts,\n });\n try {\n const output = await fn();\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'completed',\n output: output as Record<string, unknown> | undefined,\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n return output;\n } catch (err) {\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'failed',\n error: serialiseError(err, nextAttempts, false),\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n throw err;\n }\n };\n }\n\n private makeSpawnFn(run: JobRunRow) {\n return async (\n type: string,\n input: unknown,\n opts?: SpawnChildOptions,\n ): Promise<JobRun> => {\n return this.orchestrator.start(type, input, {\n parentRunId: run.id,\n parentClosePolicy: opts?.closePolicy,\n runAt: opts?.runAt,\n priority: opts?.priority,\n tags: opts?.tags,\n triggerSource: 'parent',\n triggerRef: run.id,\n });\n };\n }\n\n /**\n * Allocate the next `seq` for a given run. SELECT-max approach — runs\n * typically have <100 steps so the scan is cheap, and correctness across\n * retries is more important than the microseconds saved by an in-memory\n * counter (which would drift if the worker crashes mid-run and another\n * worker resumes via stale-claim sweep).\n */\n private async nextStepSeq(runId: string): Promise<number> {\n const result = await this.db.execute(\n sql`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM job_step WHERE job_run_id = ${runId}`,\n );\n // Driver shape varies and is NOT uniformly array-iterable, so we must\n // never array-destructure the raw result (that throws `{} is not iterable`\n // on the node-postgres `Result` object, which exposes `.rows` instead of\n // being an array — first hit by package-mode bridge deliveries on\n // `drizzle-orm/node-postgres`). Normalise to a row array first, then read.\n // - node-postgres `db.execute(sql)` → `{ rows: [{ next }], ... }`\n // - some drivers / future shapes → a plain `[{ next }]` array\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const raw = result as any;\n const rows: Array<{ next?: unknown }> = Array.isArray(raw)\n ? raw\n : Array.isArray(raw?.rows)\n ? raw.rows\n : [];\n const next = rows[0]?.next;\n return typeof next === 'undefined' ? 1 : Number(next);\n }\n\n // ============================================================================\n // (suppress unused-import noise)\n // ============================================================================\n}\n\n// Terminal statuses re-exported for JOB-4 parity imports.\nexport { TERMINAL_STATUSES };\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/jobs-domain.module.ts"],"sourcesContent":["/**\n * JobsDomainModule — `DynamicModule.forRoot({ backend })` factory wiring\n * the three jobs-domain protocol tokens to a backend implementation\n * (ADR-022, JOB-5).\n *\n * Mirrors `EventsModule.forRoot()` exactly:\n * - `global: true` so consumer entity modules don't have to import this\n * individually — `JOB_ORCHESTRATOR` / `JOB_RUN_SERVICE` /\n * `JOB_STEP_SERVICE` are available project-wide.\n * - One backend at a time (Drizzle for production, Memory for tests).\n *\n * Backend swappability follows the core/extension protocol from CLAUDE.md:\n * the three tokens are the **core contract**; backend-specific tunables\n * live under `extensions.<backend>` so opting into a feature is explicit\n * and the type system reserves the slot.\n */\nimport { Module, type DynamicModule, type Provider } from '@nestjs/common';\nimport { DRIZZLE } from '../../constants/tokens';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n JOBS_LISTEN_NOTIFY,\n} from './jobs-domain.tokens';\nimport { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';\nimport { DrizzleJobRunService } from './job-run-service.drizzle-backend';\nimport { DrizzleJobStepService } from './job-step-service.drizzle-backend';\n// #6 — `BullMQJobOrchestrator` is lazy-loaded only when `backend: 'bullmq'`\n// is selected. The backend file is filtered out of drizzle/memory installs\n// (see `backendFileFilter`); a non-literal dynamic import below sidesteps\n// consumer-side tsc resolution of an absent file.\nimport { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';\nimport { MemoryJobRunService } from './job-run-service.memory-backend';\nimport { MemoryJobStepService } from './job-step-service.memory-backend';\nimport { MemoryJobStore } from './memory-job-store';\nimport {\n BULLMQ_CONNECTION,\n BULLMQ_RESOLVED_CONFIG,\n resolveBullMqConfig,\n type BullMqExtensionsConfig,\n} from './bullmq.config';\n\n/**\n * Drizzle backend extensions surface (LISTEN-NOTIFY-1 wires both fields).\n *\n * - `listenNotify` → provided as `JOBS_LISTEN_NOTIFY` so the orchestrator emits\n * in-tx `pg_notify` on enqueue, and threaded into each spawned `JobWorker`\n * (which holds the listener connection). Off by default.\n * - `pollIntervalMs` → threaded into the spawned `JobWorker`'s\n * `JobWorkerOptions.pollIntervalMs` (the worker already honored this; it just\n * never received a config value). Default 1000.\n *\n * Both run ALONGSIDE interval polling — `listenNotify` only adds an early wake;\n * polling remains the durability heartbeat.\n */\nexport interface DrizzleBackendExtensions {\n /** Use Postgres LISTEN/NOTIFY to wake the polling loop. Default false. */\n listenNotify?: boolean;\n /** Polling interval (ms). Default 1000. */\n pollIntervalMs?: number;\n}\n\nexport interface JobsDomainModuleOptions {\n backend: 'drizzle' | 'memory' | 'bullmq';\n /**\n * Backend-specific extensions. Only the matching backend's extensions\n * are read at boot; non-matching keys are ignored. This is the\n * core/extension protocol surface — see CLAUDE.md.\n */\n extensions?: {\n drizzle?: DrizzleBackendExtensions;\n /**\n * BullMQ backend extensions (BULLMQ-1). Snake_case mirrors the YAML\n * under `jobs.extensions.bullmq`. `redis_url` falls back to\n * `process.env.REDIS_URL` then `redis://localhost:6379`.\n */\n bullmq?: BullMqExtensionsConfig;\n };\n /** Multi-tenancy opt-in. Wired by JOB-8; module signature stays stable. */\n multiTenant?: boolean;\n}\n\n@Module({})\nexport class JobsDomainModule {\n static forRoot(opts: JobsDomainModuleOptions): DynamicModule {\n const multiTenant = opts.multiTenant ?? false;\n // LISTEN-NOTIFY-1 — drizzle-only extension. `listen_notify` is meaningless\n // for memory (no DB) and redundant for bullmq (native wakeups); only the\n // drizzle backend's orchestrator reads it.\n const listenNotify =\n opts.backend === 'drizzle' && Boolean(opts.extensions?.drizzle?.listenNotify);\n\n const providers: Provider[] = [\n // JOB-8 — boolean provider consumed by the four service-layer backends.\n // Always provided (even when `multiTenant === false`) so `@Inject`\n // always resolves; backends short-circuit the enforcement path when\n // the value is `false`. See `jobs-domain.tokens.ts` for the claim-loop\n // cross-tenant-by-design decision.\n { provide: JOBS_MULTI_TENANT, useValue: multiTenant },\n // LISTEN-NOTIFY-1 — always provided so the orchestrator's `@Inject`\n // resolves; the orchestrator skips the `pg_notify` emit when `false`.\n { provide: JOBS_LISTEN_NOTIFY, useValue: listenNotify },\n ];\n\n if (opts.backend === 'memory') {\n // The store is a plain class — wired as a singleton `useValue` so\n // unit tests can pull it out via `.get(MemoryJobStore)` for direct\n // assertions (matches the access pattern in JOB-4 specs).\n const store = new MemoryJobStore();\n providers.push({ provide: MemoryJobStore, useValue: store });\n providers.push(MemoryJobStepService);\n providers.push({ provide: JOB_STEP_SERVICE, useExisting: MemoryJobStepService });\n providers.push(MemoryJobOrchestrator);\n providers.push({ provide: JOB_ORCHESTRATOR, useExisting: MemoryJobOrchestrator });\n providers.push(MemoryJobRunService);\n providers.push({ provide: JOB_RUN_SERVICE, useExisting: MemoryJobRunService });\n } else if (opts.backend === 'bullmq') {\n // BULLMQ-1 — BullMQ orchestrator over a Postgres source of truth. The\n // run/step services stay Drizzle (domain reads + `listForScope` are\n // Postgres queries, unchanged per spec). Only the orchestrator's\n // claim/dispatch half swaps to BullMQ.\n //\n // #6 — the bullmq backend module is filtered out of drizzle/memory\n // installs (no `bullmq` peer dep, no consumer-side tsc compile of an\n // unused file). The factory below dynamic-imports it via a non-literal\n // specifier so TS treats the module type as `any` and never tries to\n // resolve the absent file on a drizzle/memory consumer.\n const resolved = resolveBullMqConfig(opts.extensions?.bullmq);\n providers.push({ provide: BULLMQ_CONNECTION, useValue: resolved.connection });\n providers.push({ provide: BULLMQ_RESOLVED_CONFIG, useValue: resolved });\n providers.push({\n provide: JOB_ORCHESTRATOR,\n useFactory: async (...args: unknown[]): Promise<object> => {\n const specifier = './job-orchestrator.bullmq-backend';\n const mod = (await import(specifier)) as {\n BullMQJobOrchestrator: new (...args: unknown[]) => object;\n };\n return new mod.BullMQJobOrchestrator(...args);\n },\n // The bullmq orchestrator constructor mirrors DrizzleJobOrchestrator's\n // injection list: DRIZZLE + JOBS_MULTI_TENANT + the resolved BullMQ\n // tokens. Importing token references would force a static dep on the\n // tokens file in this module's import graph; using the existing\n // symbols already in scope is sufficient.\n inject: [DRIZZLE, JOBS_MULTI_TENANT, BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG],\n });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n } else {\n providers.push({ provide: JOB_ORCHESTRATOR, useClass: DrizzleJobOrchestrator });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n }\n\n const exports = [\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n JOBS_LISTEN_NOTIFY,\n ];\n // BULLMQ-1 — only export the BullMQ tokens when they were actually\n // provided. Nest throws \"exported but not provided\" otherwise. Exported so\n // JobWorkerModule (which imports this module) can read the resolved\n // connection/config to spawn BullMQ workers.\n if (opts.backend === 'bullmq') {\n exports.push(BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG);\n }\n\n return {\n module: JobsDomainModule,\n global: true,\n providers,\n exports,\n };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBA,SAAS,cAAiD;AAoEnD,IAAM,mBAAN,MAAuB;AAAA,EAC5B,OAAO,QAAQ,MAA8C;AAC3D,UAAM,cAAc,KAAK,eAAe;AAIxC,UAAM,eACJ,KAAK,YAAY,aAAa,QAAQ,KAAK,YAAY,SAAS,YAAY;AAE9E,UAAM,YAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAM5B,EAAE,SAAS,mBAAmB,UAAU,YAAY;AAAA;AAAA;AAAA,MAGpD,EAAE,SAAS,oBAAoB,UAAU,aAAa;AAAA,IACxD;AAEA,QAAI,KAAK,YAAY,UAAU;AAI7B,YAAM,QAAQ,IAAI,eAAe;AACjC,gBAAU,KAAK,EAAE,SAAS,gBAAgB,UAAU,MAAM,CAAC;AAC3D,gBAAU,KAAK,oBAAoB;AACnC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,qBAAqB,CAAC;AAC/E,gBAAU,KAAK,qBAAqB;AACpC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,sBAAsB,CAAC;AAChF,gBAAU,KAAK,mBAAmB;AAClC,gBAAU,KAAK,EAAE,SAAS,iBAAiB,aAAa,oBAAoB,CAAC;AAAA,IAC/E,WAAW,KAAK,YAAY,UAAU;AAWpC,YAAM,WAAW,oBAAoB,KAAK,YAAY,MAAM;AAC5D,gBAAU,KAAK,EAAE,SAAS,mBAAmB,UAAU,SAAS,WAAW,CAAC;AAC5E,gBAAU,KAAK,EAAE,SAAS,wBAAwB,UAAU,SAAS,CAAC;AACtE,gBAAU,KAAK;AAAA,QACb,SAAS;AAAA,QACT,YAAY,UAAU,SAAqC;AACzD,gBAAM,YAAY;AAClB,gBAAM,MAAO,MAAM,OAAO;AAG1B,iBAAO,IAAI,IAAI,sBAAsB,GAAG,IAAI;AAAA,QAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMA,QAAQ,CAAC,SAAS,mBAAmB,mBAAmB,sBAAsB;AAAA,MAChF,CAAC;AACD,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E,OAAO;AACL,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,uBAAuB,CAAC;AAC9E,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E;AAEA,UAAM,UAAU;AAAA,MACd;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAKA,QAAI,KAAK,YAAY,UAAU;AAC7B,cAAQ,KAAK,mBAAmB,sBAAsB;AAAA,IACxD;AAEA,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AA7Fa,mBAAN;AAAA,EADN,OAAO,CAAC,CAAC;AAAA,GACG;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/job-worker.module.ts"],"sourcesContent":["/**\n * JobWorkerModule — `DynamicModule.forRoot({ mode, pools? })` factory that\n * boots one `JobWorker` per active pool and runs the boot-time validator\n * (Drizzle only) (ADR-022, JOB-5).\n *\n * Imports `JobsDomainModule` internally so call sites only need to add\n * `JobWorkerModule.forRoot(...)` to `AppModule.imports` — the protocol\n * tokens become available transitively via `global: true`.\n *\n * Lifecycle (`onModuleInit`, **order-critical** per JOB-5 spec):\n * 1. `loadPoolConfig()` → resolved `PoolConfig`\n * 2. `HandlerRegistry.getAll()` → registered entries\n * 3. Reserved-pool validation → throws `ReservedPoolViolationError`\n * 4. `orchestrator.upsertJobRows(entries, …)` → persist `job` definitions\n * 5. Boot validator (Drizzle only) → throws `BootValidationError`\n * (skipped entirely in memory mode — Q4 resolution 2026-04-19)\n * 6. Spawn one `JobWorker` per active pool → start polling loops\n *\n * `onModuleDestroy` calls `gracefulStop` on each worker (drains in-flight,\n * resets `running` rows, removes SIGTERM handler).\n */\nimport {\n Inject,\n Injectable,\n Logger,\n Module,\n Optional,\n type DynamicModule,\n type OnModuleDestroy,\n type OnModuleInit,\n} from '@nestjs/common';\nimport { ModuleRef } from '@nestjs/core';\nimport { tokenKey } from '../token-key';\nimport { DRIZZLE } from '../../constants/tokens';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { HandlerRegistry, type HandlerRegistryEntry } from './job-handler.base';\nimport {\n JobsDomainModule,\n type JobsDomainModuleOptions,\n} from './jobs-domain.module';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n} from './jobs-domain.tokens';\nimport type { IJobOrchestrator } from './job-orchestrator.protocol';\nimport type { IJobRunService } from './job-run-service.protocol';\nimport type { IJobStepService } from './job-step-service.protocol';\nimport {\n allNonReservedPoolNames,\n allPoolNames,\n loadPoolConfig,\n type PoolConfig,\n} from './pool-config.loader';\nimport { JobWorker, type JobWorkerOptions } from './job-worker';\n// #6 — `BullMQJobWorker` is lazy-loaded only when `backend: 'bullmq'` is\n// selected (`spawnBullMQWorker` below). The file is filtered out of drizzle/\n// memory installs (see `backendFileFilter`). The `ConnectionOptions` type\n// previously imported from `'bullmq'` is replaced by `BullMqConnectionOptions`\n// from `./bullmq.config` (a self-contained structural mirror that does NOT\n// require the `bullmq` peer dep to type-check).\nimport {\n BULLMQ_CONNECTION,\n BULLMQ_RESOLVED_CONFIG,\n resolvePoolQueueName,\n type BullMqConnectionOptions,\n type BullMqResolvedConfig,\n} from './bullmq.config';\nimport {\n BootValidationError,\n ReservedPoolViolationError,\n} from './jobs-errors';\n\nconst DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;\n\nexport interface JobWorkerModuleOptions {\n mode: 'embedded' | 'standalone';\n /**\n * Threads into the internal `JobsDomainModule.forRoot({ backend })`\n * import. Default `'drizzle'`. The boot-time validator runs for both\n * `'drizzle'` and `'bullmq'` (both persist `job` rows to Postgres);\n * `'memory'` skips it.\n */\n backend?: 'drizzle' | 'memory' | 'bullmq';\n /**\n * Active pool names. Defaults to every non-reserved pool in the resolved\n * config (i.e. `interactive`, `batch`, plus any user-defined pools).\n * Operators reduce this to one or two pools per worker process to scale\n * horizontally.\n */\n pools?: string[];\n /**\n * BULLMQ-1 Phase 1 — when `true`, `onModuleInit` activates **every** pool\n * in the resolved config, including the reserved `events_*` lanes. This is\n * how the standalone worker (`worker.ts`) drains bridge wrappers without\n * the consumer hand-listing `...BRIDGE_RESERVED_POOLS`. Mutually exclusive\n * with an explicit `pools` list — when both are set, `pools` wins (explicit\n * beats blanket) and `allPools` is ignored.\n *\n * `BridgeModule`'s reserved-pool guard short-circuits to \"pass\" when this\n * is `true`, since every reserved pool is provably being polled.\n */\n allPools?: boolean;\n /** SIGTERM drain budget. Default 30_000 ms. */\n shutdownTimeoutMs?: number;\n /**\n * Test-only — point the pool config loader at a specific YAML file.\n * Production code reads `${process.cwd()}/codegen.config.yaml`.\n */\n configPath?: string;\n /**\n * Forwarded into the inner `JobsDomainModule.forRoot()` call so the\n * worker module's caller can configure backend extensions in one place.\n */\n domainModuleExtensions?: JobsDomainModuleOptions['extensions'];\n /** Forwarded into `JobsDomainModule.forRoot()`. JOB-8 wires this. */\n multiTenant?: boolean;\n /**\n * Test-only escape hatch — when set, the module uses this factory\n * instead of `new JobWorker(...)` so unit tests can stub the worker\n * without spinning up the polling loop.\n */\n workerFactory?: (options: JobWorkerOptions) => Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>;\n}\n\n/**\n * DI token for the resolved `JobWorkerModuleOptions`. Exported so other\n * subsystems can inject it `@Optional()` and inspect the active\n * configuration — e.g. `BridgeModule.onModuleInit` checks\n * `options.pools` against `BRIDGE_RESERVED_POOLS` to fail fast when a\n * reserved pool isn't being polled (BRIDGE-8).\n *\n * ADR-037: namespaced `Symbol.for(...)` (via `tokenKey()`) — matches by value\n * across runtime copies.\n */\nexport const JOB_WORKER_MODULE_OPTIONS = Symbol.for(tokenKey('jobs', 'worker-module-options'));\n\n/**\n * The lifecycle holder. Named `JobWorkerOrchestrator` in the spec to avoid\n * collision with `JobWorker` and `IJobOrchestrator`. Registered as a\n * provider on `JobWorkerModule`; Nest invokes `onModuleInit` /\n * `onModuleDestroy` automatically.\n */\n@Injectable()\nexport class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {\n private readonly logger = new Logger(JobWorkerOrchestrator.name);\n private readonly workers: Array<Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>> = [];\n\n constructor(\n @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,\n @Inject(JOB_RUN_SERVICE) private readonly runService: IJobRunService,\n @Inject(JOB_STEP_SERVICE) private readonly stepService: IJobStepService,\n @Inject(JOB_WORKER_MODULE_OPTIONS)\n private readonly options: JobWorkerModuleOptions,\n /**\n * Drizzle client is only required when `backend === 'drizzle'`. Made\n * `@Optional()` so memory-mode boots in `Test.createTestingModule`\n * without supplying a `DRIZZLE` provider.\n */\n @Optional() @Inject(DRIZZLE) private readonly db: DrizzleClient | null = null,\n /**\n * ADR-037 (package-mode DI): inject `ModuleRef` EXPLICITLY via `@Inject`\n * rather than relying on `design:paramtypes` reflection. The published\n * package bundle is built without `emitDecoratorMetadata` (tsup/esbuild\n * default), so a by-type injection here would resolve to `undefined` at\n * boot in package mode — breaking the worker entirely (the\n * `ModuleRef not available` throw). Vendored mode happened to work only\n * because the consumer's own `tsc` (emitDecoratorMetadata: true)\n * recompiled the source and emitted the metadata. The explicit token is\n * mode-agnostic. `ModuleRef` is always provided by `@nestjs/core`, so no\n * `@Optional()` is needed (it's a hard dependency of the worker path).\n */\n @Inject(ModuleRef) private readonly moduleRef?: ModuleRef,\n /**\n * BULLMQ-1 — resolved BullMQ connection + config, only bound when the\n * inner `JobsDomainModule` was booted with `backend: 'bullmq'`. `@Optional()`\n * so drizzle/memory boots see `null`.\n */\n @Optional()\n @Inject(BULLMQ_CONNECTION)\n private readonly bullConnection: BullMqConnectionOptions | null = null,\n @Optional()\n @Inject(BULLMQ_RESOLVED_CONFIG)\n private readonly bullConfig: BullMqResolvedConfig | null = null,\n ) {}\n\n // ============================================================================\n // Lifecycle\n // ============================================================================\n\n async onModuleInit(): Promise<void> {\n const backend = this.options.backend ?? 'drizzle';\n\n // (1) Pool config first — every later step needs the resolved map.\n const poolConfig = loadPoolConfig(this.options.configPath);\n\n // (2) Snapshot the registry. Decorators run at class-load time so the\n // map is fully populated before any module init fires.\n const entries = HandlerRegistry.getAll();\n\n // (3) Reserved-pool validation BEFORE the upsert. Persisting a\n // reserved-pool handler row would leave the DB in a bad state for\n // the next boot to clean up.\n this.assertNoReservedPoolHandlers(entries, poolConfig);\n\n // (4) Upsert `job` definitions. Drizzle: hash-gated `ON CONFLICT DO\n // UPDATE`. Memory: populates `MemoryJobStore.jobs` + handler-class\n // registry.\n const { orphaned } = await this.orchestrator.upsertJobRows(\n entries,\n poolConfig,\n );\n\n // (5) Boot validator — Drizzle only. Memory mode never has DB rows\n // to validate (Q4 resolution 2026-04-19); the equivalent\n // protection is `MemoryJobOrchestrator.start()` throwing\n // `JobTypeNotFoundError` synchronously for unknown types.\n if (backend !== 'memory' && orphaned.length > 0) {\n throw new BootValidationError(orphaned);\n }\n\n // (6) Resolve active pool list and spawn one worker per pool.\n // Precedence: explicit `pools` > `allPools` (incl. reserved) >\n // non-reserved default. BULLMQ-1 Phase 1 adds the `allPools` rung so\n // the standalone worker drains the reserved `events_*` bridge lanes.\n const activePools = this.options.pools\n ? this.options.pools\n : this.options.allPools\n ? allPoolNames(poolConfig)\n : allNonReservedPoolNames(poolConfig);\n\n for (const poolName of activePools) {\n const def = poolConfig.get(poolName);\n if (!def) {\n throw new Error(\n `JobWorkerModule: active pool '${poolName}' is not defined in ` +\n `the resolved pool config. Configured pools: [${[...poolConfig.keys()].join(', ')}].`,\n );\n }\n // `pool` here is the logical pool name (e.g. 'crm_sync') — the same\n // value the orchestrator persists into `job_run.pool` from\n // `@JobHandler.meta.pool`, and therefore the value the worker's\n // claim query filters on. `def.queue` is a display/routing alias\n // (e.g. 'jobs-crm-sync') used by BullMQ-style backends for queue\n // naming; it MUST NOT be passed as the claim-filter pool, or the\n // worker will never match any row and the pool silently never\n // drains. See v0.4.4 fix notes.\n // LISTEN-NOTIFY-1 — thread the drizzle extension knobs into each spawned\n // worker. `pollIntervalMs` was always honored by JobWorker but never\n // received a config value; `listenNotify` is the new wake opt-in. Only\n // the drizzle backend reads these (bullmq has native wakeups + its own\n // queue topology), so we ignore them under `backend: 'bullmq'`.\n const drizzleExt =\n backend === 'drizzle'\n ? this.options.domainModuleExtensions?.drizzle\n : undefined;\n const workerOptions: JobWorkerOptions = {\n pool: poolName,\n concurrency: def.concurrency,\n shutdownTimeoutMs:\n this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,\n pollIntervalMs: drizzleExt?.pollIntervalMs,\n listenNotify: drizzleExt?.listenNotify,\n };\n const worker = this.options.workerFactory\n ? this.options.workerFactory(workerOptions)\n : backend === 'bullmq'\n ? await this.spawnBullMQWorker(poolName, def.queue, def.concurrency, poolConfig)\n : this.spawnWorker(workerOptions);\n // `JobWorker` extends Nest's lifecycle hooks but the worker isn't\n // a Nest provider here (we manage the array ourselves). Call\n // `onModuleInit` to start the loop. The Drizzle/stub workers return\n // void; `BullMQJobWorker.onModuleInit` is async (it lazily loads the\n // optional `bullmq` package), so we `await` — awaiting a `void` is a\n // harmless no-op for the synchronous workers.\n await worker.onModuleInit();\n this.workers.push(worker);\n this.logger.log(\n `JobWorker started: pool='${poolName}' (queue='${def.queue}') ` +\n `concurrency=${def.concurrency} backend='${backend}'`,\n );\n }\n }\n\n async onModuleDestroy(): Promise<void> {\n // Tear down in reverse order so the most recently started worker\n // drains first — keeps the SIGTERM handler graph predictable.\n for (let i = this.workers.length - 1; i >= 0; i--) {\n const worker = this.workers[i];\n if (!worker) continue;\n try {\n await worker.onModuleDestroy();\n } catch (err) {\n this.logger.error(\n `JobWorker shutdown failed: ${(err as Error).message}`,\n );\n }\n }\n this.workers.length = 0;\n\n // BULLMQ-1 — close the orchestrator's producer-side Queue/FlowProducer\n // connections so the process can exit cleanly. The orchestrator is the\n // BullMQ producer; workers are the consumers (closed above).\n const orch = this.orchestrator as { closeConnections?: () => Promise<void> };\n if (typeof orch.closeConnections === 'function') {\n try {\n await orch.closeConnections();\n } catch (err) {\n this.logger.error(\n `BullMQ orchestrator connection close failed: ${(err as Error).message}`,\n );\n }\n }\n }\n\n // ============================================================================\n // Internals\n // ============================================================================\n\n /**\n * Walk every registered handler; collect any whose declared `pool`\n * targets a reserved pool from the resolved config. If non-empty,\n * throw `ReservedPoolViolationError` with the offender list so the\n * operator sees every violating class on a single boot.\n */\n private assertNoReservedPoolHandlers(\n entries: HandlerRegistryEntry[],\n poolConfig: PoolConfig,\n ): void {\n const offenders: Array<{ handlerClass: string; pool: string }> = [];\n for (const entry of entries) {\n // Framework-owned handlers (`@framework/*` job types) are allowed in\n // reserved pools — that is in fact the entire point of the reserved\n // `events_*` pools (ADR-022 + ADR-023). The reserved-pool guard\n // exists to keep USER handlers out, not the framework's own\n // bridge-delivery handler. BRIDGE-5 introduced this exemption.\n if (entry.type.startsWith('@framework/')) continue;\n const declaredPool = entry.meta.pool ?? 'batch';\n const def = poolConfig.get(declaredPool);\n if (def?.reserved) {\n offenders.push({\n handlerClass: entry.handlerClass.name,\n pool: declaredPool,\n });\n }\n }\n if (offenders.length > 0) {\n throw new ReservedPoolViolationError(offenders);\n }\n }\n\n /**\n * Production worker spawn. `JobWorker` requires `DRIZZLE` so this only\n * succeeds when the module was booted with `backend: 'drizzle'`. Memory\n * mode tests must supply `workerFactory` — the memory backend has no\n * polling loop equivalent (`MemoryJobOrchestrator` is direct-invocation\n * only).\n *\n * We instantiate outside the Nest container because the module spawns\n * N workers from a single options shape, which doesn't fit Nest's\n * \"one provider per token\" model. The dependencies are passed\n * positionally; the constructor's `@Inject` decorators are unused on\n * this path (Nest still uses them when `JobWorker` is a provider — e.g.\n * in JOB-6's standalone `worker.ts` entrypoint).\n */\n private spawnWorker(workerOptions: JobWorkerOptions): JobWorker {\n if (!this.db) {\n throw new Error(\n `JobWorkerModule: in-process worker spawning requires the Drizzle ` +\n `backend (no DRIZZLE provider available). Memory-mode tests must ` +\n `pass 'workerFactory' to inject a stub.`,\n );\n }\n if (!this.moduleRef) {\n throw new Error(\n `JobWorkerModule: ModuleRef not available — cannot construct JobWorker ` +\n `with handler DI support. Ensure the orchestrator is resolved through ` +\n `the Nest container (not instantiated manually in tests).`,\n );\n }\n return new JobWorker(\n this.db,\n this.orchestrator,\n this.runService,\n this.stepService,\n workerOptions,\n this.moduleRef,\n );\n }\n\n /**\n * BULLMQ-1 — spawn a per-pool `BullMQJobWorker`. Requires the Drizzle\n * client (the worker drives `job_run` as the source of truth) AND the\n * resolved BullMQ connection (bound by `JobsDomainModule` when\n * `backend: 'bullmq'`). The queue name is derived identically to the\n * orchestrator's `dispatch` via `resolvePoolQueueName(pool, …)` so producer\n * and consumer agree.\n */\n /**\n * #6 — async + dynamic-import. The `job-worker.bullmq-backend.ts` file is\n * filtered out of the vendor set for drizzle/memory installs (no `bullmq`\n * peer dep needed). The non-literal import specifier makes TS treat the\n * module as `any` so the consumer's tsc never tries to resolve an absent\n * file. This method is only entered when `backend === 'bullmq'` — at which\n * point the file IS vendored.\n */\n private async spawnBullMQWorker(\n pool: string,\n _queueAlias: string,\n concurrency: number,\n poolConfig: PoolConfig,\n ): Promise<Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>> {\n if (!this.db) {\n throw new Error(\n `JobWorkerModule: BullMQ worker spawning requires the Drizzle client ` +\n `(no DRIZZLE provider available) — job_run remains the source of truth.`,\n );\n }\n if (!this.bullConnection) {\n throw new Error(\n `JobWorkerModule: BullMQ worker spawning requires a resolved ` +\n `BULLMQ_CONNECTION. Ensure JobsDomainModule was booted with ` +\n `backend: 'bullmq'.`,\n );\n }\n if (!this.moduleRef) {\n throw new Error(\n `JobWorkerModule: ModuleRef not available — cannot construct ` +\n `BullMQJobWorker with handler DI support.`,\n );\n }\n const queueName = resolvePoolQueueName(pool, this.bullConfig, poolConfig);\n const specifier = './job-worker.bullmq-backend';\n const mod = (await import(specifier)) as {\n BullMQJobWorker: new (...args: unknown[]) => Pick<\n JobWorker,\n 'onModuleInit' | 'onModuleDestroy'\n >;\n };\n return new mod.BullMQJobWorker(\n this.db,\n this.orchestrator,\n this.stepService,\n {\n pool,\n queueName,\n concurrency,\n connection: this.bullConnection,\n },\n this.moduleRef,\n );\n }\n}\n\n@Module({})\nexport class JobWorkerModule {\n static forRoot(opts: JobWorkerModuleOptions): DynamicModule {\n return {\n module: JobWorkerModule,\n imports: [\n JobsDomainModule.forRoot({\n backend: opts.backend ?? 'drizzle',\n extensions: opts.domainModuleExtensions,\n multiTenant: opts.multiTenant,\n }),\n ],\n providers: [\n { provide: JOB_WORKER_MODULE_OPTIONS, useValue: opts },\n JobWorkerOrchestrator,\n ],\n // BULLMQ-1 Phase 1 — export the options token so `BridgeModule`'s\n // reserved-pool guard (`onModuleInit`) can actually inject it.\n // Previously `exports: []` left the `@Optional()` inject resolving to\n // `undefined` and the guard silently no-opped (a dead check). With the\n // token exported the guard fires for real; consumers that omit the\n // reserved pools (and don't set `allPools`) now fail fast with\n // `BridgeReservedPoolsNotPolledError` — which is correct.\n exports: [JOB_WORKER_MODULE_OPTIONS],\n };\n }\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/events/event-bus.drizzle-backend.ts"],"sourcesContent":["/**\n * DrizzleEventBus — Postgres-backed event bus using the transactional outbox pattern.\n *\n * Events are inserted into the `domain_events` table within the caller's\n * Drizzle transaction. A background polling loop (started on module init)\n * reads unprocessed events and dispatches them to registered subscribers.\n *\n * When the transaction rolls back, the event is never persisted — no\n * phantom events.\n *\n * Pool awareness (EVT-4):\n * - On `publish`/`publishMany` the backend writes `metadata.pool`,\n * `metadata.direction`, and `metadata.tenantId` into the first-class\n * `pool` / `direction` / `tenant_id` columns (metadata JSON is still\n * written unchanged for protocol stability).\n * - The drain loop filters by `opts.pools` when provided, so separate\n * processes (e.g. one per `events_inbound` / `events_change` /\n * `events_outbound`) can claim only their own lane. `pools: undefined`\n * drains all pending rows (backwards-compatible behaviour).\n *\n * EVT-Q7: No stale-event sweeper. `FOR UPDATE SKIP LOCKED` is\n * self-healing — the row is only locked for the duration of the\n * enclosing polling transaction; the `status='processed'` update happens\n * within that same transaction. There is no `claimed_at` semantic (unlike\n * jobs), so no stale rows can exist.\n *\n * This backend is suitable until you need real-time fan-out or very high\n * throughput. At that point, swap the backend for Redis Streams or similar\n * via EventsModule.forRoot({ backend: '...' }) without touching use cases.\n */\nimport { randomUUID } from 'node:crypto';\nimport { Injectable, OnModuleDestroy, OnModuleInit, Inject, Logger, Optional } from '@nestjs/common';\nimport { eq, and, inArray, asc, desc, gte, lt, or, sql, type SQL } from 'drizzle-orm';\nimport type {\n DomainEvent,\n DrizzleTransaction,\n IEventBus,\n ScheduledEventSpec,\n} from './event-bus.protocol';\nimport type {\n EventPage,\n IEventReadPort,\n ListEventsQuery,\n} from './event-read.protocol';\nimport {\n clampEventLimit,\n decodeEventCursor,\n encodeEventCursor,\n} from './event-keyset-cursor';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { domainEvents, type DomainEventRecord } from './domain-events.schema';\nimport { DRIZZLE } from '../../constants/tokens';\nimport { EVENTS_MODULE_OPTIONS } from './events.tokens';\nimport type { EventsModuleOptions } from './events.module';\nimport { BRIDGE_OUTBOX_DRAIN_HOOK } from '../bridge/bridge.tokens';\nimport type { IBridgeOutboxDrainHook } from '../bridge/bridge.protocol';\nimport {\n EVENTS_WAKE_CHANNEL,\n PgNotifyListener,\n pgNotify,\n} from '../jobs/pg-notify';\n\n/** How long to wait between polling cycles (ms). */\nconst POLL_INTERVAL_MS = 1_000;\n/** Max events claimed per polling cycle to bound memory usage. */\nconst POLL_BATCH_SIZE = 50;\n\n/**\n * Row shape built from `metadata` for writing into `domain_events`. Keeps\n * the per-event extraction logic in one place so publish/publishMany stay\n * in sync.\n */\nfunction toInsertValues(event: DomainEvent, multiTenant: boolean) {\n const metadata = event.metadata ?? undefined;\n const pool = (metadata?.['pool'] as string | undefined) ?? null;\n const direction = (metadata?.['direction'] as string | undefined) ?? null;\n // AUDIT-1: tier defaults to 'domain' when absent. The DB CHECK\n // constraint (`domain_events_tier_routing_check`) enforces the\n // tier ⇔ routing-fields invariant at the storage boundary; no\n // JS-side assertion is needed here.\n const tier = (metadata?.['tier'] as string | undefined) ?? 'domain';\n const base = {\n id: event.id,\n type: event.type,\n aggregateId: event.aggregateId,\n aggregateType: event.aggregateType,\n payload: event.payload,\n occurredAt: event.occurredAt,\n processedAt: null,\n status: 'pending' as const,\n metadata: event.metadata,\n pool,\n direction,\n tier,\n };\n // EVT-8: `tenant_id` is a scaffold-time conditional column, emitted only\n // when `events.multi_tenant: true`. Only write it when multi-tenancy is\n // on — under single-tenant scaffolds the column does not exist, so the\n // key must be omitted from the insert.\n if (!multiTenant) return base;\n const tenantId = (metadata?.['tenantId'] as string | undefined) ?? null;\n return { ...base, tenantId };\n}\n\n/**\n * Project a raw `domain_events` row into the narrow `EventSummary` shape.\n * Shared with the memory backend via this helper kept module-local to each\n * backend (the events subsystem has no cross-backend projection file yet;\n * the two are byte-identical and small).\n */\nfunction toEventSummary(r: DomainEventRecord) {\n const metadata = (r.metadata ?? undefined) as\n | Record<string, unknown>\n | undefined;\n const rootRunId = metadata?.['rootRunId'];\n return {\n id: r.id,\n type: r.type,\n aggregateId: r.aggregateId,\n aggregateType: r.aggregateType,\n status: r.status,\n pool: r.pool,\n direction: r.direction,\n tier: r.tier,\n rootRunId: typeof rootRunId === 'string' ? rootRunId : null,\n // EVT-8: `tenant_id` is a scaffold-time conditional column. Read it\n // structurally so this projection typechecks against both the\n // multi-tenant schema (column present) and the single-tenant schema\n // (column absent → undefined → null).\n tenantId: (r as { tenantId?: string | null }).tenantId ?? null,\n occurredAt:\n r.occurredAt instanceof Date\n ? r.occurredAt\n : new Date(r.occurredAt as unknown as string),\n processedAt:\n r.processedAt == null\n ? null\n : r.processedAt instanceof Date\n ? r.processedAt\n : new Date(r.processedAt as unknown as string),\n };\n}\n\n/**\n * Postgres unique-violation (SQLSTATE 23505) test. Used by the scheduled-event\n * materialiser (ADR-039) to treat a slot-key collision as the\n * already-materialised no-op. Reads `.code` defensively across driver shapes\n * (node-postgres surfaces it on the error, some wrappers nest it on `.cause`).\n */\nfunction isUniqueViolation(err: unknown): boolean {\n const code = (err as { code?: unknown; cause?: { code?: unknown } } | undefined);\n return code?.code === '23505' || code?.cause?.code === '23505';\n}\n\n@Injectable()\nexport class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit, OnModuleDestroy {\n private readonly logger = new Logger(DrizzleEventBus.name);\n private polling = false;\n private pollTimer: ReturnType<typeof setTimeout> | null = null;\n private readonly handlers = new Map<string, Set<(event: DomainEvent) => Promise<void>>>();\n private readonly opts: EventsModuleOptions;\n\n // LISTEN-NOTIFY-1 — dedicated wake listener + debounce state. `null` when\n // `listenNotify` is off (the common case); polling is the only driver then.\n private notifyListener: PgNotifyListener | null = null;\n /** True while a wake-driven drain is in flight (debounce gate). */\n private wakeDraining = false;\n /** A notify arrived mid-drain → re-drain once when the current drain ends. */\n private wakeRecheckPending = false;\n\n constructor(\n @Inject(DRIZZLE) private readonly db: DrizzleClient,\n @Optional() @Inject(EVENTS_MODULE_OPTIONS) opts?: EventsModuleOptions,\n /**\n * Bridge subsystem hook (BRIDGE-4). Optional — when the bridge\n * subsystem is not installed in the consuming app, this token is\n * undefined and the drain skips the bridge block entirely (preserves\n * EVT-4 baseline behaviour).\n *\n * When provided, `processEvent` is invoked once per drained event\n * INSIDE the per-event tx, before `processed_at` is stamped. The\n * hook owns all knowledge of `bridge_delivery + wrapper job_run`\n * shapes; the events subsystem stays unaware of bridge schemas.\n */\n @Optional()\n @Inject(BRIDGE_OUTBOX_DRAIN_HOOK)\n private readonly bridgeHook: IBridgeOutboxDrainHook | null = null,\n ) {\n // Default so direct construction (e.g. integration tests not going\n // through Nest DI) keeps working without an explicit options object.\n this.opts = opts ?? { backend: 'drizzle' };\n }\n\n // ============================================================================\n // Lifecycle\n // ============================================================================\n\n async onModuleInit(): Promise<void> {\n this.polling = true;\n this.schedulePoll();\n\n // LISTEN-NOTIFY-1 — start the wake listener ALONGSIDE the poll timer. A\n // notify for one of this drainer's pools triggers an immediate drain; the\n // interval timer above stays the durability heartbeat. Startup is\n // fire-and-forget — a connect failure self-heals via the listener's backoff.\n if (this.opts.listenNotify) {\n const pool = (this.db as unknown as { $client?: unknown }).$client;\n if (!pool || typeof (pool as { connect?: unknown }).connect !== 'function') {\n this.logger.warn(\n `listen_notify enabled but the Drizzle client exposes no pg Pool ` +\n `($client.connect missing) — falling back to interval polling only.`,\n );\n } else {\n this.notifyListener = new PgNotifyListener({\n channel: EVENTS_WAKE_CHANNEL,\n pool: pool as { connect(): Promise<never> },\n label: 'events',\n onNotify: (payload) => this.onWake(payload),\n });\n await this.notifyListener.start();\n }\n }\n }\n\n async onModuleDestroy(): Promise<void> {\n this.polling = false;\n if (this.pollTimer) {\n clearTimeout(this.pollTimer);\n this.pollTimer = null;\n }\n if (this.notifyListener) {\n try {\n await this.notifyListener.stop();\n } catch (err) {\n this.logger.error(`notify listener stop failed: ${err}`);\n }\n this.notifyListener = null;\n }\n }\n\n /**\n * Wake handler — a `codegen_events_wake` notification arrived. A pool-filtered\n * drainer (`opts.pools` set) ignores payloads naming a pool it doesn't own; an\n * all-pools drainer wakes for any. Debounced: a notify mid-drain just flags a\n * re-check so a burst collapses to at most one extra drain (D3).\n */\n private onWake(payload: string): void {\n if (!this.polling) return;\n const pools = this.opts.pools;\n if (pools && pools.length > 0 && !pools.includes(payload)) return;\n if (this.wakeDraining) {\n this.wakeRecheckPending = true;\n return;\n }\n void this.drainOnWake();\n }\n\n private async drainOnWake(): Promise<void> {\n this.wakeDraining = true;\n try {\n do {\n this.wakeRecheckPending = false;\n await this.processBatch();\n } while (this.wakeRecheckPending && this.polling);\n } catch (err) {\n this.logger.error(`wake drain error: ${err}`);\n } finally {\n this.wakeDraining = false;\n }\n }\n\n // ============================================================================\n // IEventBus\n // ============================================================================\n\n async publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void> {\n const client = (tx ?? this.db) as DrizzleClient;\n const multiTenant = this.opts.multiTenant ?? false;\n const values = toInsertValues(event, multiTenant);\n await client.insert(domainEvents).values(values);\n // LISTEN-NOTIFY-1 — wake the drainer on commit (D2: emitted through the same\n // `client`, so a rolled-back publish emits no phantom wake). The pool is the\n // payload; the drainer re-runs its own pool-filtered claim on wake.\n await this.emitWakeNotify(client, [values.pool]);\n }\n\n async publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void> {\n if (events.length === 0) return;\n const client = (tx ?? this.db) as DrizzleClient;\n const multiTenant = this.opts.multiTenant ?? false;\n const valuesList = events.map((e) => toInsertValues(e, multiTenant));\n await client.insert(domainEvents).values(valuesList);\n // De-dup pools so a batch into one lane emits a single wake.\n await this.emitWakeNotify(client, valuesList.map((v) => v.pool));\n }\n\n /**\n * Emit one in-tx `pg_notify(codegen_events_wake, <pool>)` per distinct pool in\n * the just-inserted batch. No-op unless `listenNotify` is on. Best-effort: a\n * notify failure is non-fatal (interval polling still drains the rows), so we\n * log + swallow rather than failing the publish.\n */\n private async emitWakeNotify(\n client: DrizzleClient,\n pools: Array<string | null>,\n ): Promise<void> {\n if (!this.opts.listenNotify) return;\n const distinct = new Set(pools.map((p) => p ?? ''));\n for (const pool of distinct) {\n try {\n await pgNotify(client, EVENTS_WAKE_CHANNEL, pool);\n } catch (err) {\n this.logger.warn(\n `pg_notify(${EVENTS_WAKE_CHANNEL}, '${pool}') failed: ${err} ` +\n `(non-fatal — interval polling still drains the outbox).`,\n );\n }\n }\n }\n\n async findById(eventId: string): Promise<DomainEvent | null> {\n const rows = await this.db\n .select()\n .from(domainEvents)\n .where(eq(domainEvents.id, eventId))\n .limit(1);\n const row = rows[0];\n if (!row) return null;\n return {\n id: row.id,\n type: row.type,\n aggregateId: row.aggregateId,\n aggregateType: row.aggregateType,\n payload: row.payload as Record<string, unknown>,\n occurredAt:\n row.occurredAt instanceof Date\n ? row.occurredAt\n : new Date(row.occurredAt as unknown as string),\n metadata: (row.metadata ?? undefined) as\n | Record<string, unknown>\n | undefined,\n };\n }\n\n subscribe<T extends DomainEvent = DomainEvent>(\n eventType: string,\n handler: (event: T) => Promise<void>,\n ): () => void {\n if (!this.handlers.has(eventType)) {\n this.handlers.set(eventType, new Set());\n }\n const set = this.handlers.get(eventType)!;\n const h = handler as (event: DomainEvent) => Promise<void>;\n set.add(h);\n return () => {\n set.delete(h);\n };\n }\n\n // ============================================================================\n // ADR-039 — scheduled-event materialisation (time as an event source)\n // ============================================================================\n\n /**\n * Insert one scheduled tick event idempotently. The slot key is stamped onto\n * `metadata.scheduleSlot`; `ON CONFLICT DO NOTHING` against the partial UNIQUE\n * expression index `idx_domain_events_schedule_slot` makes a duplicate insert\n * a no-op — the DB constraint is the exactly-one-event-per-slot invariant.\n *\n * Reuses the standard outbox row shape (pool/direction/metadata) so the\n * existing drain carries the tick like any other event. A LISTEN/NOTIFY wake\n * fires for an immediately-due tick (boot/catch-up rows whose slot is already\n * in the past); a future slot is claimed by polling once `occurred_at` passes.\n */\n async materializeScheduledEvent(\n spec: ScheduledEventSpec,\n ): Promise<{ created: boolean }> {\n const multiTenant = this.opts.multiTenant ?? false;\n const metadata: Record<string, unknown> = {\n pool: spec.pool,\n direction: spec.direction,\n scheduleSlot: spec.slotKey,\n triggerSource: 'schedule',\n };\n const base = {\n id: randomUUID(),\n type: spec.type,\n // Payload-free scheduled fact (the dealbrain strict-producer pattern).\n aggregateId: spec.type,\n aggregateType: spec.type,\n payload: {} as Record<string, unknown>,\n occurredAt: spec.slotStart,\n processedAt: null,\n status: 'pending' as const,\n metadata,\n pool: spec.pool,\n direction: spec.direction,\n tier: 'domain' as const,\n };\n const values = multiTenant ? { ...base, tenantId: null } : base;\n\n // The idempotency guard is the partial UNIQUE expression index\n // `idx_domain_events_schedule_slot` on (type, metadata->>'scheduleSlot').\n // Drizzle 0.45's typed `onConflictDoNothing({ target })` only accepts\n // columns, so it can't name an expression index — we instead let the\n // insert run and treat a unique-violation (SQLSTATE 23505) as the\n // already-materialised no-op. This is the exactly-one-per-slot invariant:\n // a concurrent or repeat materialise of the same slot loses the race at\n // the DB and reports `created: false`.\n try {\n await this.db.insert(domainEvents).values(values);\n } catch (err) {\n if (isUniqueViolation(err)) return { created: false };\n throw err;\n }\n\n // Wake the drainer for an already-due tick. A future slot waits for polling.\n if (spec.slotStart.getTime() <= Date.now()) {\n await this.emitWakeNotify(this.db, [spec.pool]);\n }\n return { created: true };\n }\n\n /** Most recent scheduled tick's `occurred_at` (epoch ms) for `type`, or null.\n * Read by the scheduler's catch-up backfill. */\n async lastScheduledSlotMs(type: string): Promise<number | null> {\n const rows = await this.db\n .select({ occurredAt: domainEvents.occurredAt })\n .from(domainEvents)\n .where(\n and(\n eq(domainEvents.type, type),\n sql`${domainEvents.metadata} ->> 'triggerSource' = 'schedule'`,\n ),\n )\n .orderBy(desc(domainEvents.occurredAt))\n .limit(1);\n const row = rows[0];\n if (!row?.occurredAt) return null;\n return row.occurredAt instanceof Date\n ? row.occurredAt.getTime()\n : new Date(row.occurredAt as unknown as string).getTime();\n }\n\n // ============================================================================\n // IEventReadPort (OBS-LIST-1)\n // ============================================================================\n\n async listEvents(query: ListEventsQuery = {}): Promise<EventPage> {\n const limit = clampEventLimit(query.limit);\n const conditions: SQL<unknown>[] = [];\n\n if (query.poolId) conditions.push(eq(domainEvents.pool, query.poolId));\n if (query.direction)\n conditions.push(eq(domainEvents.direction, query.direction));\n if (query.since) conditions.push(gte(domainEvents.occurredAt, query.since));\n if (query.rootRunId) {\n // Filter on the JSON correlation id: metadata->>'rootRunId'.\n conditions.push(\n sql`${domainEvents.metadata}->>'rootRunId' = ${query.rootRunId}`,\n );\n }\n // EVT-8: `tenant_id` is a scaffold-time conditional column (emitted only\n // under `events.multi_tenant: true`). Guard the filter behind the same\n // `multiTenant` flag, and read the column structurally so this backend\n // typechecks against both the multi-tenant schema (column present) and\n // the single-tenant schema (column absent). When multi-tenancy is off\n // there is no `tenant_id` column to filter on.\n if (this.opts.multiTenant && query.tenantId !== undefined) {\n const tenantIdColumn = (\n domainEvents as unknown as { tenantId: typeof domainEvents.pool }\n ).tenantId;\n conditions.push(\n query.tenantId === null\n ? (sql`${tenantIdColumn} is null` as SQL<unknown>)\n : eq(tenantIdColumn, query.tenantId),\n );\n }\n\n // Keyset seek: WHERE (occurred_at, id) < (cursorOccurredAt, cursorId).\n if (query.cursor) {\n const keyset = decodeEventCursor(query.cursor);\n if (keyset) {\n conditions.push(\n or(\n lt(domainEvents.occurredAt, keyset.occurredAt),\n and(\n eq(domainEvents.occurredAt, keyset.occurredAt),\n lt(domainEvents.id, keyset.id),\n ),\n )!,\n );\n }\n }\n\n const rows = (await this.db\n .select()\n .from(domainEvents)\n .where(conditions.length > 0 ? and(...conditions) : undefined)\n .orderBy(desc(domainEvents.occurredAt), desc(domainEvents.id))\n .limit(limit + 1)) as DomainEventRecord[];\n\n const hasMore = rows.length > limit;\n const page = hasMore ? rows.slice(0, limit) : rows;\n const items = page.map(toEventSummary);\n const last = page[page.length - 1];\n const nextCursor =\n hasMore && last\n ? encodeEventCursor({ occurredAt: last.occurredAt, id: last.id })\n : null;\n\n return { items, nextCursor };\n }\n\n // ============================================================================\n // Polling\n // ============================================================================\n\n /**\n * Test-only hook. Runs exactly one drain cycle and returns. Production\n * code goes through `onModuleInit` → `schedulePoll`, which calls the\n * same `processBatch` under a timer.\n */\n async drainOnce(): Promise<void> {\n await this.processBatch();\n }\n\n private schedulePoll(): void {\n if (!this.polling) return;\n this.pollTimer = setTimeout(async () => {\n try {\n await this.processBatch();\n } catch (err) {\n this.logger.error(`Poll cycle error: ${err}`);\n } finally {\n this.schedulePoll();\n }\n }, POLL_INTERVAL_MS);\n }\n\n /**\n * Drain one batch (BRIDGE-4 restructure of EVT-4).\n *\n * Two-phase per drained event:\n *\n * 1. **Per-event transaction** — bridge fanout (`bridgeHook.processEvent`)\n * + `processed_at` stamp. Both write through the same `tx`. A throw\n * inside the tx (only infra-level failures should reach here, since\n * the hook tolerates null direction and registry misses inline)\n * rolls back the bridge inserts AND the `processed_at` stamp; the\n * event re-claims on the next drain cycle. Bridge `UNIQUE\n * (event_id, trigger_id)` makes the retry idempotent.\n *\n * 2. **After commit** — dispatch in-process subscribers (`IEventBus.subscribe`\n * handlers). This deliberately runs OUTSIDE the per-event tx (lead\n * decision 2026-04-22): subscribers are best-effort and must not\n * gate forward progress or roll back bridge fanout. Subscriber\n * errors are caught + logged; `processed_at` is already committed.\n * The old `MAX_RETRIES=3` in-process retry loop and the\n * `failed`-stamping path were removed in BRIDGE-4 along with their\n * coupling.\n *\n * The `processed_at` UPDATE carries `AND status='pending'` (BRIDGE-4\n * tightening — without it, a hypothetical double-claim could double-stamp\n * the timestamp). The per-event tx + `FOR UPDATE SKIP LOCKED` claim\n * make this defensive belt-and-suspenders.\n */\n private async processBatch(): Promise<void> {\n const pools = this.opts.pools;\n\n // Build WHERE: status='pending' [AND pool IN (...)]\n const whereClause: SQL<unknown> = pools && pools.length > 0\n ? (and(eq(domainEvents.status, 'pending'), inArray(domainEvents.pool, pools)) as SQL<unknown>)\n : eq(domainEvents.status, 'pending');\n\n // Claim a batch with FOR UPDATE SKIP LOCKED so multiple pollers don't\n // double-dispatch. The lock is released when the outer transaction\n // commits — which is fine because the immediately-following per-event\n // tx flips status='processed' under its own `AND status='pending'`\n // guard, so a re-claim of the same row in a subsequent batch is a\n // no-op UPDATE.\n const rows = await this.db.transaction(async (tx) => {\n return tx\n .select()\n .from(domainEvents)\n .where(whereClause)\n .orderBy(asc(domainEvents.occurredAt))\n .limit(POLL_BATCH_SIZE)\n .for('update', { skipLocked: true });\n }) as Array<typeof domainEvents.$inferSelect>;\n\n for (const row of rows) {\n const event: DomainEvent = {\n id: row.id,\n type: row.type,\n aggregateId: row.aggregateId,\n aggregateType: row.aggregateType,\n payload: row.payload as Record<string, unknown>,\n occurredAt: row.occurredAt instanceof Date ? row.occurredAt : new Date(row.occurredAt as unknown as string),\n metadata: (row.metadata ?? undefined) as Record<string, unknown> | undefined,\n };\n\n // Phase 1 — per-event tx: bridge fanout + processed_at stamp.\n try {\n await this.db.transaction(async (tx) => {\n if (this.bridgeHook) {\n await this.bridgeHook.processEvent(event, tx);\n }\n await tx\n .update(domainEvents)\n .set({ status: 'processed', processedAt: new Date() })\n .where(\n and(\n eq(domainEvents.id, event.id),\n eq(domainEvents.status, 'pending'),\n ),\n );\n });\n } catch (err) {\n // Infra-level failure inside the per-event tx — bridge inserts\n // and processed_at both rolled back. Log and move on; the next\n // drain cycle re-claims the row. UNIQUE on bridge_delivery makes\n // the retry idempotent.\n this.logger.error(\n `Per-event tx failed for event id=${event.id} type=${event.type}: ${err}`,\n );\n continue;\n }\n\n // Phase 2 — best-effort subscriber dispatch. Errors are logged\n // and discarded; processed_at is already committed. Subscribers\n // are observability + cache-busts + small ancillary work; they\n // must not gate forward progress.\n try {\n await this.dispatch(event);\n } catch (err) {\n this.logger.error(\n `Subscriber dispatch failed for event id=${event.id} type=${event.type} ` +\n `(processed_at already committed; failure does not retry): ${err}`,\n );\n }\n }\n }\n\n private async dispatch(event: DomainEvent): Promise<void> {\n const set = this.handlers.get(event.type);\n if (!set) return;\n\n let firstError: unknown;\n for (const handler of set) {\n try {\n await handler(event);\n } catch (err) {\n this.logger.error(\n `Handler error for event type \"${event.type}\" (id: ${event.id}): ${err}`,\n );\n if (firstError === undefined) {\n firstError = err;\n }\n }\n }\n\n if (firstError !== undefined) {\n throw firstError;\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAS,kBAAkB;AAC3B,SAAS,YAA2C,QAAQ,QAAQ,gBAAgB;AACpF,SAAS,IAAI,KAAK,SAAS,KAAK,MAAM,KAAK,IAAI,IAAI,WAAqB;AA+BxE,IAAM,mBAAmB;AAEzB,IAAM,kBAAkB;AAOxB,SAAS,eAAe,OAAoB,aAAsB;AAChE,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,OAAQ,WAAW,MAAM,KAA4B;AAC3D,QAAM,YAAa,WAAW,WAAW,KAA4B;AAKrE,QAAM,OAAQ,WAAW,MAAM,KAA4B;AAC3D,QAAM,OAAO;AAAA,IACX,IAAI,MAAM;AAAA,IACV,MAAM,MAAM;AAAA,IACZ,aAAa,MAAM;AAAA,IACnB,eAAe,MAAM;AAAA,IACrB,SAAS,MAAM;AAAA,IACf,YAAY,MAAM;AAAA,IAClB,aAAa;AAAA,IACb,QAAQ;AAAA,IACR,UAAU,MAAM;AAAA,IAChB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAKA,MAAI,CAAC,YAAa,QAAO;AACzB,QAAM,WAAY,WAAW,UAAU,KAA4B;AACnE,SAAO,EAAE,GAAG,MAAM,SAAS;AAC7B;AAQA,SAAS,eAAe,GAAsB;AAC5C,QAAM,WAAY,EAAE,YAAY;AAGhC,QAAM,YAAY,WAAW,WAAW;AACxC,SAAO;AAAA,IACL,IAAI,EAAE;AAAA,IACN,MAAM,EAAE;AAAA,IACR,aAAa,EAAE;AAAA,IACf,eAAe,EAAE;AAAA,IACjB,QAAQ,EAAE;AAAA,IACV,MAAM,EAAE;AAAA,IACR,WAAW,EAAE;AAAA,IACb,MAAM,EAAE;AAAA,IACR,WAAW,OAAO,cAAc,WAAW,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA,IAKvD,UAAW,EAAmC,YAAY;AAAA,IAC1D,YACE,EAAE,sBAAsB,OACpB,EAAE,aACF,IAAI,KAAK,EAAE,UAA+B;AAAA,IAChD,aACE,EAAE,eAAe,OACb,OACA,EAAE,uBAAuB,OACvB,EAAE,cACF,IAAI,KAAK,EAAE,WAAgC;AAAA,EACrD;AACF;AAQA,SAAS,kBAAkB,KAAuB;AAChD,QAAM,OAAQ;AACd,SAAO,MAAM,SAAS,WAAW,MAAM,OAAO,SAAS;AACzD;AAGO,IAAM,kBAAN,MAA0F;AAAA,EAe/F,YACoC,IACS,MAc1B,aAA4C,MAC7D;AAhBkC;AAejB;AAIjB,SAAK,OAAO,QAAQ,EAAE,SAAS,UAAU;AAAA,EAC3C;AAAA,EApBoC;AAAA,EAejB;AAAA,EA9BF,SAAS,IAAI,OAAO,gBAAgB,IAAI;AAAA,EACjD,UAAU;AAAA,EACV,YAAkD;AAAA,EACzC,WAAW,oBAAI,IAAwD;AAAA,EACvE;AAAA;AAAA;AAAA,EAIT,iBAA0C;AAAA;AAAA,EAE1C,eAAe;AAAA;AAAA,EAEf,qBAAqB;AAAA;AAAA;AAAA;AAAA,EA6B7B,MAAM,eAA8B;AAClC,SAAK,UAAU;AACf,SAAK,aAAa;AAMlB,QAAI,KAAK,KAAK,cAAc;AAC1B,YAAM,OAAQ,KAAK,GAAwC;AAC3D,UAAI,CAAC,QAAQ,OAAQ,KAA+B,YAAY,YAAY;AAC1E,aAAK,OAAO;AAAA,UACV;AAAA,QAEF;AAAA,MACF,OAAO;AACL,aAAK,iBAAiB,IAAI,iBAAiB;AAAA,UACzC,SAAS;AAAA,UACT;AAAA,UACA,OAAO;AAAA,UACP,UAAU,CAAC,YAAY,KAAK,OAAO,OAAO;AAAA,QAC5C,CAAC;AACD,cAAM,KAAK,eAAe,MAAM;AAAA,MAClC;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,kBAAiC;AACrC,SAAK,UAAU;AACf,QAAI,KAAK,WAAW;AAClB,mBAAa,KAAK,SAAS;AAC3B,WAAK,YAAY;AAAA,IACnB;AACA,QAAI,KAAK,gBAAgB;AACvB,UAAI;AACF,cAAM,KAAK,eAAe,KAAK;AAAA,MACjC,SAAS,KAAK;AACZ,aAAK,OAAO,MAAM,gCAAgC,GAAG,EAAE;AAAA,MACzD;AACA,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQQ,OAAO,SAAuB;AACpC,QAAI,CAAC,KAAK,QAAS;AACnB,UAAM,QAAQ,KAAK,KAAK;AACxB,QAAI,SAAS,MAAM,SAAS,KAAK,CAAC,MAAM,SAAS,OAAO,EAAG;AAC3D,QAAI,KAAK,cAAc;AACrB,WAAK,qBAAqB;AAC1B;AAAA,IACF;AACA,SAAK,KAAK,YAAY;AAAA,EACxB;AAAA,EAEA,MAAc,cAA6B;AACzC,SAAK,eAAe;AACpB,QAAI;AACF,SAAG;AACD,aAAK,qBAAqB;AAC1B,cAAM,KAAK,aAAa;AAAA,MAC1B,SAAS,KAAK,sBAAsB,KAAK;AAAA,IAC3C,SAAS,KAAK;AACZ,WAAK,OAAO,MAAM,qBAAqB,GAAG,EAAE;AAAA,IAC9C,UAAE;AACA,WAAK,eAAe;AAAA,IACtB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,QAAQ,OAAoB,IAAwC;AACxE,UAAM,SAAU,MAAM,KAAK;AAC3B,UAAM,cAAc,KAAK,KAAK,eAAe;AAC7C,UAAM,SAAS,eAAe,OAAO,WAAW;AAChD,UAAM,OAAO,OAAO,YAAY,EAAE,OAAO,MAAM;AAI/C,UAAM,KAAK,eAAe,QAAQ,CAAC,OAAO,IAAI,CAAC;AAAA,EACjD;AAAA,EAEA,MAAM,YAAY,QAAuB,IAAwC;AAC/E,QAAI,OAAO,WAAW,EAAG;AACzB,UAAM,SAAU,MAAM,KAAK;AAC3B,UAAM,cAAc,KAAK,KAAK,eAAe;AAC7C,UAAM,aAAa,OAAO,IAAI,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC;AACnE,UAAM,OAAO,OAAO,YAAY,EAAE,OAAO,UAAU;AAEnD,UAAM,KAAK,eAAe,QAAQ,WAAW,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAc,eACZ,QACA,OACe;AACf,QAAI,CAAC,KAAK,KAAK,aAAc;AAC7B,UAAM,WAAW,IAAI,IAAI,MAAM,IAAI,CAAC,MAAM,KAAK,EAAE,CAAC;AAClD,eAAW,QAAQ,UAAU;AAC3B,UAAI;AACF,cAAM,SAAS,QAAQ,qBAAqB,IAAI;AAAA,MAClD,SAAS,KAAK;AACZ,aAAK,OAAO;AAAA,UACV,aAAa,mBAAmB,MAAM,IAAI,cAAc,GAAG;AAAA,QAE7D;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,SAA8C;AAC3D,UAAM,OAAO,MAAM,KAAK,GACrB,OAAO,EACP,KAAK,YAAY,EACjB,MAAM,GAAG,aAAa,IAAI,OAAO,CAAC,EAClC,MAAM,CAAC;AACV,UAAM,MAAM,KAAK,CAAC;AAClB,QAAI,CAAC,IAAK,QAAO;AACjB,WAAO;AAAA,MACL,IAAI,IAAI;AAAA,MACR,MAAM,IAAI;AAAA,MACV,aAAa,IAAI;AAAA,MACjB,eAAe,IAAI;AAAA,MACnB,SAAS,IAAI;AAAA,MACb,YACE,IAAI,sBAAsB,OACtB,IAAI,aACJ,IAAI,KAAK,IAAI,UAA+B;AAAA,MAClD,UAAW,IAAI,YAAY;AAAA,IAG7B;AAAA,EACF;AAAA,EAEA,UACE,WACA,SACY;AACZ,QAAI,CAAC,KAAK,SAAS,IAAI,SAAS,GAAG;AACjC,WAAK,SAAS,IAAI,WAAW,oBAAI,IAAI,CAAC;AAAA,IACxC;AACA,UAAM,MAAM,KAAK,SAAS,IAAI,SAAS;AACvC,UAAM,IAAI;AACV,QAAI,IAAI,CAAC;AACT,WAAO,MAAM;AACX,UAAI,OAAO,CAAC;AAAA,IACd;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,MAAM,0BACJ,MAC+B;AAC/B,UAAM,cAAc,KAAK,KAAK,eAAe;AAC7C,UAAM,WAAoC;AAAA,MACxC,MAAM,KAAK;AAAA,MACX,WAAW,KAAK;AAAA,MAChB,cAAc,KAAK;AAAA,MACnB,eAAe;AAAA,IACjB;AACA,UAAM,OAAO;AAAA,MACX,IAAI,WAAW;AAAA,MACf,MAAM,KAAK;AAAA;AAAA,MAEX,aAAa,KAAK;AAAA,MAClB,eAAe,KAAK;AAAA,MACpB,SAAS,CAAC;AAAA,MACV,YAAY,KAAK;AAAA,MACjB,aAAa;AAAA,MACb,QAAQ;AAAA,MACR;AAAA,MACA,MAAM,KAAK;AAAA,MACX,WAAW,KAAK;AAAA,MAChB,MAAM;AAAA,IACR;AACA,UAAM,SAAS,cAAc,EAAE,GAAG,MAAM,UAAU,KAAK,IAAI;AAU3D,QAAI;AACF,YAAM,KAAK,GAAG,OAAO,YAAY,EAAE,OAAO,MAAM;AAAA,IAClD,SAAS,KAAK;AACZ,UAAI,kBAAkB,GAAG,EAAG,QAAO,EAAE,SAAS,MAAM;AACpD,YAAM;AAAA,IACR;AAGA,QAAI,KAAK,UAAU,QAAQ,KAAK,KAAK,IAAI,GAAG;AAC1C,YAAM,KAAK,eAAe,KAAK,IAAI,CAAC,KAAK,IAAI,CAAC;AAAA,IAChD;AACA,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB;AAAA;AAAA;AAAA,EAIA,MAAM,oBAAoB,MAAsC;AAC9D,UAAM,OAAO,MAAM,KAAK,GACrB,OAAO,EAAE,YAAY,aAAa,WAAW,CAAC,EAC9C,KAAK,YAAY,EACjB;AAAA,MACC;AAAA,QACE,GAAG,aAAa,MAAM,IAAI;AAAA,QAC1B,MAAM,aAAa,QAAQ;AAAA,MAC7B;AAAA,IACF,EACC,QAAQ,KAAK,aAAa,UAAU,CAAC,EACrC,MAAM,CAAC;AACV,UAAM,MAAM,KAAK,CAAC;AAClB,QAAI,CAAC,KAAK,WAAY,QAAO;AAC7B,WAAO,IAAI,sBAAsB,OAC7B,IAAI,WAAW,QAAQ,IACvB,IAAI,KAAK,IAAI,UAA+B,EAAE,QAAQ;AAAA,EAC5D;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WAAW,QAAyB,CAAC,GAAuB;AAChE,UAAM,QAAQ,gBAAgB,MAAM,KAAK;AACzC,UAAM,aAA6B,CAAC;AAEpC,QAAI,MAAM,OAAQ,YAAW,KAAK,GAAG,aAAa,MAAM,MAAM,MAAM,CAAC;AACrE,QAAI,MAAM;AACR,iBAAW,KAAK,GAAG,aAAa,WAAW,MAAM,SAAS,CAAC;AAC7D,QAAI,MAAM,MAAO,YAAW,KAAK,IAAI,aAAa,YAAY,MAAM,KAAK,CAAC;AAC1E,QAAI,MAAM,WAAW;AAEnB,iBAAW;AAAA,QACT,MAAM,aAAa,QAAQ,oBAAoB,MAAM,SAAS;AAAA,MAChE;AAAA,IACF;AAOA,QAAI,KAAK,KAAK,eAAe,MAAM,aAAa,QAAW;AACzD,YAAM,iBACJ,aACA;AACF,iBAAW;AAAA,QACT,MAAM,aAAa,OACd,MAAM,cAAc,aACrB,GAAG,gBAAgB,MAAM,QAAQ;AAAA,MACvC;AAAA,IACF;AAGA,QAAI,MAAM,QAAQ;AAChB,YAAM,SAAS,kBAAkB,MAAM,MAAM;AAC7C,UAAI,QAAQ;AACV,mBAAW;AAAA,UACT;AAAA,YACE,GAAG,aAAa,YAAY,OAAO,UAAU;AAAA,YAC7C;AAAA,cACE,GAAG,aAAa,YAAY,OAAO,UAAU;AAAA,cAC7C,GAAG,aAAa,IAAI,OAAO,EAAE;AAAA,YAC/B;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,UAAM,OAAQ,MAAM,KAAK,GACtB,OAAO,EACP,KAAK,YAAY,EACjB,MAAM,WAAW,SAAS,IAAI,IAAI,GAAG,UAAU,IAAI,MAAS,EAC5D,QAAQ,KAAK,aAAa,UAAU,GAAG,KAAK,aAAa,EAAE,CAAC,EAC5D,MAAM,QAAQ,CAAC;AAElB,UAAM,UAAU,KAAK,SAAS;AAC9B,UAAM,OAAO,UAAU,KAAK,MAAM,GAAG,KAAK,IAAI;AAC9C,UAAM,QAAQ,KAAK,IAAI,cAAc;AACrC,UAAM,OAAO,KAAK,KAAK,SAAS,CAAC;AACjC,UAAM,aACJ,WAAW,OACP,kBAAkB,EAAE,YAAY,KAAK,YAAY,IAAI,KAAK,GAAG,CAAC,IAC9D;AAEN,WAAO,EAAE,OAAO,WAAW;AAAA,EAC7B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,MAAM,YAA2B;AAC/B,UAAM,KAAK,aAAa;AAAA,EAC1B;AAAA,EAEQ,eAAqB;AAC3B,QAAI,CAAC,KAAK,QAAS;AACnB,SAAK,YAAY,WAAW,YAAY;AACtC,UAAI;AACF,cAAM,KAAK,aAAa;AAAA,MAC1B,SAAS,KAAK;AACZ,aAAK,OAAO,MAAM,qBAAqB,GAAG,EAAE;AAAA,MAC9C,UAAE;AACA,aAAK,aAAa;AAAA,MACpB;AAAA,IACF,GAAG,gBAAgB;AAAA,EACrB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA6BA,MAAc,eAA8B;AAC1C,UAAM,QAAQ,KAAK,KAAK;AAGxB,UAAM,cAA4B,SAAS,MAAM,SAAS,IACrD,IAAI,GAAG,aAAa,QAAQ,SAAS,GAAG,QAAQ,aAAa,MAAM,KAAK,CAAC,IAC1E,GAAG,aAAa,QAAQ,SAAS;AAQrC,UAAM,OAAO,MAAM,KAAK,GAAG,YAAY,OAAO,OAAO;AACnD,aAAO,GACJ,OAAO,EACP,KAAK,YAAY,EACjB,MAAM,WAAW,EACjB,QAAQ,IAAI,aAAa,UAAU,CAAC,EACpC,MAAM,eAAe,EACrB,IAAI,UAAU,EAAE,YAAY,KAAK,CAAC;AAAA,IACvC,CAAC;AAED,eAAW,OAAO,MAAM;AACtB,YAAM,QAAqB;AAAA,QACzB,IAAI,IAAI;AAAA,QACR,MAAM,IAAI;AAAA,QACV,aAAa,IAAI;AAAA,QACjB,eAAe,IAAI;AAAA,QACnB,SAAS,IAAI;AAAA,QACb,YAAY,IAAI,sBAAsB,OAAO,IAAI,aAAa,IAAI,KAAK,IAAI,UAA+B;AAAA,QAC1G,UAAW,IAAI,YAAY;AAAA,MAC7B;AAGA,UAAI;AACF,cAAM,KAAK,GAAG,YAAY,OAAO,OAAO;AACtC,cAAI,KAAK,YAAY;AACnB,kBAAM,KAAK,WAAW,aAAa,OAAO,EAAE;AAAA,UAC9C;AACA,gBAAM,GACH,OAAO,YAAY,EACnB,IAAI,EAAE,QAAQ,aAAa,aAAa,oBAAI,KAAK,EAAE,CAAC,EACpD;AAAA,YACC;AAAA,cACE,GAAG,aAAa,IAAI,MAAM,EAAE;AAAA,cAC5B,GAAG,aAAa,QAAQ,SAAS;AAAA,YACnC;AAAA,UACF;AAAA,QACJ,CAAC;AAAA,MACH,SAAS,KAAK;AAKZ,aAAK,OAAO;AAAA,UACV,oCAAoC,MAAM,EAAE,SAAS,MAAM,IAAI,KAAK,GAAG;AAAA,QACzE;AACA;AAAA,MACF;AAMA,UAAI;AACF,cAAM,KAAK,SAAS,KAAK;AAAA,MAC3B,SAAS,KAAK;AACZ,aAAK,OAAO;AAAA,UACV,2CAA2C,MAAM,EAAE,SAAS,MAAM,IAAI,8DACP,GAAG;AAAA,QACpE;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,SAAS,OAAmC;AACxD,UAAM,MAAM,KAAK,SAAS,IAAI,MAAM,IAAI;AACxC,QAAI,CAAC,IAAK;AAEV,QAAI;AACJ,eAAW,WAAW,KAAK;AACzB,UAAI;AACF,cAAM,QAAQ,KAAK;AAAA,MACrB,SAAS,KAAK;AACZ,aAAK,OAAO;AAAA,UACV,iCAAiC,MAAM,IAAI,UAAU,MAAM,EAAE,MAAM,GAAG;AAAA,QACxE;AACA,YAAI,eAAe,QAAW;AAC5B,uBAAa;AAAA,QACf;AAAA,MACF;AAAA,IACF;AAEA,QAAI,eAAe,QAAW;AAC5B,YAAM;AAAA,IACR;AAAA,EACF;AACF;AA/fa,kBAAN;AAAA,EADN,WAAW;AAAA,EAiBP,0BAAO,OAAO;AAAA,EACd,4BAAS;AAAA,EAAG,0BAAO,qBAAqB;AAAA,EAYxC,4BAAS;AAAA,EACT,0BAAO,wBAAwB;AAAA,GA9BvB;","names":[]}