@pattern-stack/codegen 0.4.1 → 0.4.3

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 (158) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -0
  3. package/dist/runtime/subsystems/bridge/bridge.module.js +38 -21
  4. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  5. package/dist/runtime/subsystems/bridge/index.d.ts +1 -0
  6. package/dist/runtime/subsystems/bridge/index.js +29 -12
  7. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  8. package/dist/runtime/subsystems/index.js +31 -14
  9. package/dist/runtime/subsystems/index.js.map +1 -1
  10. package/dist/runtime/subsystems/jobs/index.d.ts +1 -0
  11. package/dist/runtime/subsystems/jobs/index.js +27 -10
  12. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  13. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +3 -1
  14. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +9 -4
  15. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
  16. package/dist/runtime/subsystems/jobs/job-worker.d.ts +3 -1
  17. package/dist/runtime/subsystems/jobs/job-worker.js +6 -2
  18. package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
  19. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +3 -1
  20. package/dist/runtime/subsystems/jobs/job-worker.module.js +27 -10
  21. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  22. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -4
  23. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  24. package/dist/src/cli/index.js +29 -2
  25. package/dist/src/cli/index.js.map +1 -1
  26. package/package.json +2 -1
  27. package/runtime/analytics/index.ts +31 -0
  28. package/runtime/analytics/metrics.ts +85 -0
  29. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  30. package/runtime/analytics/packs/index.ts +5 -0
  31. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  32. package/runtime/analytics/specs.ts +54 -0
  33. package/runtime/analytics/types.ts +105 -0
  34. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  35. package/runtime/base-classes/activity-entity-service.ts +48 -0
  36. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  37. package/runtime/base-classes/base-repository.ts +289 -0
  38. package/runtime/base-classes/base-service.ts +183 -0
  39. package/runtime/base-classes/index.ts +38 -0
  40. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  41. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  42. package/runtime/base-classes/lifecycle-events.ts +152 -0
  43. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  44. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  45. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  46. package/runtime/base-classes/synced-entity-service.ts +50 -0
  47. package/runtime/base-classes/with-analytics.ts +22 -0
  48. package/runtime/constants/tokens.ts +29 -0
  49. package/runtime/eav-helpers.ts +74 -0
  50. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  51. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  52. package/runtime/shared/openapi/errors.ts +39 -0
  53. package/runtime/shared/openapi/index.ts +20 -0
  54. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  55. package/runtime/shared/openapi/registry.ts +151 -0
  56. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  57. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  58. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  59. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  60. package/runtime/subsystems/analytics/index.ts +15 -0
  61. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  62. package/runtime/subsystems/auth/auth.module.ts +91 -0
  63. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  64. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  65. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  66. package/runtime/subsystems/auth/index.ts +77 -0
  67. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  68. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  69. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  70. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  71. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  72. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  73. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  74. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  75. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  76. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  77. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  78. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  79. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  80. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  81. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  82. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  83. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  84. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  85. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  86. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  87. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  88. package/runtime/subsystems/bridge/index.ts +84 -0
  89. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  90. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  91. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  92. package/runtime/subsystems/cache/cache.module.ts +115 -0
  93. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  94. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  95. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  96. package/runtime/subsystems/cache/index.ts +22 -0
  97. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  98. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  99. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  100. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  101. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  102. package/runtime/subsystems/events/events-errors.ts +30 -0
  103. package/runtime/subsystems/events/events.module.ts +230 -0
  104. package/runtime/subsystems/events/events.tokens.ts +62 -0
  105. package/runtime/subsystems/events/generated/bus.ts +103 -0
  106. package/runtime/subsystems/events/generated/index.ts +7 -0
  107. package/runtime/subsystems/events/generated/registry.ts +84 -0
  108. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  109. package/runtime/subsystems/events/generated/types.ts +94 -0
  110. package/runtime/subsystems/events/index.ts +21 -0
  111. package/runtime/subsystems/index.ts +63 -0
  112. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  113. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  114. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  115. package/runtime/subsystems/jobs/index.ts +120 -0
  116. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  117. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  118. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  119. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +860 -0
  120. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  121. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  122. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  123. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  124. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  125. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  126. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  127. package/runtime/subsystems/jobs/job-worker.module.ts +312 -0
  128. package/runtime/subsystems/jobs/job-worker.ts +624 -0
  129. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  130. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  131. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  132. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  133. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  134. package/runtime/subsystems/storage/index.ts +18 -0
  135. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  136. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  137. package/runtime/subsystems/storage/storage.module.ts +60 -0
  138. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  139. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  140. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  141. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  142. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  143. package/runtime/subsystems/sync/index.ts +98 -0
  144. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  145. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  146. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  147. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  148. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  149. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  150. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  151. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  152. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  153. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  154. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  155. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  156. package/runtime/subsystems/sync/sync.module.ts +156 -0
  157. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  158. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,179 @@
1
+ /**
2
+ * IJobOrchestrator — the primary port for the job orchestration domain
3
+ * (ADR-022, JOB-2).
4
+ *
5
+ * Consumers (use cases, event subscribers) inject this via
6
+ * `@Inject(JOB_ORCHESTRATOR)` and call `start` / `cancel` / `replay`.
7
+ * Concrete backends (JOB-3 Drizzle, JOB-4 Memory) satisfy this contract.
8
+ *
9
+ * Single-layer architecture reminder: there is no `IJobQueue` executor port.
10
+ * The orchestrator writes `job_run` rows directly; the `JobWorker` of JOB-3
11
+ * polls `job_run` via `SELECT ... FOR UPDATE SKIP LOCKED`.
12
+ */
13
+ import type { JobRunRow } from './job-orchestration.schema';
14
+ import type { JobHandlerMeta, ParentClosePolicy } from './job-handler.base';
15
+
16
+ /**
17
+ * Public return type for orchestrator reads. Re-exported as `JobRun` so
18
+ * protocols and consumer code don't import the raw Drizzle row name.
19
+ */
20
+ export type JobRun = JobRunRow;
21
+
22
+ export interface StartOptions {
23
+ /**
24
+ * Optional scope attachment. `listForScope` queries use this pair; the
25
+ * column is free-text (no CHECK constraint) — type safety for `entityType`
26
+ * lives at the TS layer via JOB-7's generated `ScopeEntityType` union.
27
+ */
28
+ scope?: { entityType: string; entityId: string };
29
+
30
+ /** Overrides the pool declared in `@JobHandler({ pool })` metadata. */
31
+ pool?: string;
32
+
33
+ /** Schedule the run. When omitted, run as soon as the worker claims it. */
34
+ runAt?: Date;
35
+
36
+ /** 0 = default; higher values claimed first by `ORDER BY priority DESC`. */
37
+ priority?: number;
38
+
39
+ /** Free-form routing/audit tags. Persisted on `job_run.tags`. */
40
+ tags?: Record<string, string>;
41
+
42
+ /** Must align with `triggerSourceEnum` values landed in JOB-1. */
43
+ triggerSource?: 'manual' | 'schedule' | 'event' | 'parent';
44
+
45
+ /** Optional reference to the triggering event, schedule, etc. */
46
+ triggerRef?: string;
47
+
48
+ /**
49
+ * What happens to this run's children if this run reaches a terminal
50
+ * state. Stored on the child at spawn time; see `ParentClosePolicy`.
51
+ */
52
+ parentClosePolicy?: ParentClosePolicy;
53
+
54
+ /** Internal — set by `ctx.spawnChild`. User code should not pass this. */
55
+ parentRunId?: string;
56
+
57
+ /**
58
+ * Multi-tenancy opt-in (JOB-8). When `JobsDomainModule` is configured
59
+ * with `multiTenant: true`, this field is required:
60
+ * - `string` — tenant the run belongs to (written to `job_run.tenant_id`).
61
+ * - `null` — cross-tenant background work; row persisted with NULL.
62
+ * - `undefined` — throws `MissingTenantIdError` at the backend.
63
+ * When `multiTenant: false`, the field is ignored and the column is
64
+ * always written as `NULL`.
65
+ */
66
+ tenantId?: string | null;
67
+ }
68
+
69
+ export interface CancelOptions {
70
+ /**
71
+ * Conceptually defaults to `true` for root cancellation — cascading via
72
+ * `root_run_id` is the expected behaviour when an operator cancels a
73
+ * run. Backends in JOB-3/JOB-4 implement the default; callers passing
74
+ * `false` opt into "cancel only this node, leave descendants".
75
+ */
76
+ cascade?: boolean;
77
+ reason?: string;
78
+
79
+ /**
80
+ * Multi-tenancy gate (JOB-8). When `multiTenant: true`, the backend
81
+ * additionally filters `WHERE tenant_id = :tenantId` — cancelling a run
82
+ * that belongs to a different tenant is a **no-op** (not an error), so
83
+ * cross-tenant cancellation attempts are silent rather than leaking
84
+ * existence information. `undefined` throws `MissingTenantIdError`;
85
+ * explicit `null` matches `tenant_id IS NULL` rows.
86
+ */
87
+ tenantId?: string | null;
88
+ }
89
+
90
+ /**
91
+ * Boot-time upsert payload — one entry per registered `@JobHandler` class.
92
+ * Constructed by `JobWorkerModule.onModuleInit` from `HandlerRegistry.getAll()`
93
+ * and handed to the orchestrator so each backend can persist `job` definitions
94
+ * in whatever way it stores them (Drizzle: `ON CONFLICT (type) DO UPDATE`
95
+ * gated by metadata content; memory: populate `MemoryJobStore.jobs`).
96
+ */
97
+ export interface JobUpsertEntry {
98
+ type: string;
99
+ meta: JobHandlerMeta<unknown>;
100
+ /**
101
+ * Handler class constructor — the memory backend keeps a reference for
102
+ * `tick()` execution. Drizzle backend ignores this (worker resolves the
103
+ * class via `JOB_HANDLER_REGISTRY` at claim time).
104
+ */
105
+ handlerClass: new (...args: unknown[]) => unknown;
106
+ }
107
+
108
+ /**
109
+ * Pool definition surface as the orchestrator needs it for boot-time row
110
+ * materialisation. Defined locally here (not imported from
111
+ * `pool-config.loader.ts`) so the protocol layer keeps zero dependencies on
112
+ * runtime config wiring — the loader's `PoolDefinition` is structurally
113
+ * compatible.
114
+ */
115
+ export interface JobPoolDef {
116
+ queue: string;
117
+ concurrency: number;
118
+ reserved: boolean;
119
+ description?: string;
120
+ }
121
+
122
+ export interface IJobOrchestrator {
123
+ /**
124
+ * Create a `pending` `job_run` row and return it. Does NOT block waiting
125
+ * for the worker to pick the run up; consumers that need completion
126
+ * semantics should subscribe to the emitted completion event.
127
+ *
128
+ * **Optional `tx` last-arg** (added 2026-04-22 for ADR-023 BRIDGE-7):
129
+ * pass an in-flight Drizzle transaction to thread the row insert + any
130
+ * dedupe/collision lookups onto an existing tx. Used by
131
+ * `EventFlowService.publishAndStart` to bundle the outbox insert,
132
+ * eager `job_run`, and `bridge_delivery` Case-B pre-write into one
133
+ * atomic transaction. Memory backend ignores the parameter (its
134
+ * "transaction" is a process-wide mutex). Drizzle backend uses the
135
+ * standard `tx ?? this.db` pattern.
136
+ */
137
+ start(
138
+ type: string,
139
+ input: unknown,
140
+ opts?: StartOptions,
141
+ tx?: import('../events/event-bus.protocol').DrizzleTransaction,
142
+ ): Promise<JobRun>;
143
+
144
+ /**
145
+ * Cancel a run (and, by default, its entire root-run subtree). Idempotent
146
+ * — cancelling an already-terminal run is a no-op.
147
+ */
148
+ cancel(runId: string, opts?: CancelOptions): Promise<void>;
149
+
150
+ /**
151
+ * Re-run from the policy declared in `@JobHandler({ replayFrom })`.
152
+ * Returns the new `job_run` row (replay always spawns a fresh row —
153
+ * the original is preserved for audit).
154
+ */
155
+ replay(runId: string): Promise<JobRun>;
156
+
157
+ /**
158
+ * Boot-time materialisation of `job` definitions from `@JobHandler`
159
+ * metadata. Called once per process by `JobWorkerModule.onModuleInit`.
160
+ *
161
+ * Drizzle backend: hash-gated `INSERT … ON CONFLICT (type) DO UPDATE …
162
+ * WHERE` (Q3 resolution 2026-04-19). The `UPDATE` branch executes only
163
+ * when one of the persisted metadata fields differs from the incoming
164
+ * payload; `version` bumps only on a real change; concurrent boots with
165
+ * identical content are idempotent no-ops.
166
+ *
167
+ * Memory backend: populates `MemoryJobStore.jobs` and the in-process
168
+ * handler-class registry consumed by `MemoryJobOrchestrator.tick`.
169
+ *
170
+ * Returns the orphaned types — types present in DB but absent from
171
+ * `entries`. The caller (boot validator) decides whether to throw or
172
+ * warn. Memory backend always returns `[]` (Q4 resolution 2026-04-19 —
173
+ * validator skipped in memory mode).
174
+ */
175
+ upsertJobRows(
176
+ entries: JobUpsertEntry[],
177
+ poolConfig: ReadonlyMap<string, JobPoolDef>,
178
+ ): Promise<{ orphaned: string[] }>;
179
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * DrizzleJobRunService — scope-oriented reads and bulk operations against
3
+ * `job_run` (ADR-022, JOB-3).
4
+ *
5
+ * Separate from the orchestrator because the access pattern differs: this
6
+ * service scans by `(scope_entity_type, scope_entity_id)` via
7
+ * `idx_job_run_scope`, whereas orchestrator mutates individual runs by id.
8
+ */
9
+ import { Inject, Injectable } from '@nestjs/common';
10
+ import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
11
+ import type { DrizzleClient } from '../../types/drizzle';
12
+ import { DRIZZLE } from '../../constants/tokens';
13
+ import { jobRuns, type JobRunRow } from './job-orchestration.schema';
14
+ import type { JobRun } from './job-orchestrator.protocol';
15
+ import type {
16
+ IJobRunService,
17
+ ListForScopeOptions,
18
+ CancelForScopeOptions,
19
+ RescheduleForScopeOptions,
20
+ } from './job-run-service.protocol';
21
+ import type { IJobOrchestrator } from './job-orchestrator.protocol';
22
+ import { JOB_ORCHESTRATOR, JOBS_MULTI_TENANT } from './jobs-domain.tokens';
23
+ import { MissingTenantIdError } from './jobs-errors';
24
+
25
+ const NON_TERMINAL_STATUSES: JobRunRow['status'][] = [
26
+ 'pending',
27
+ 'running',
28
+ 'waiting',
29
+ ];
30
+
31
+ @Injectable()
32
+ export class DrizzleJobRunService implements IJobRunService {
33
+ constructor(
34
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
35
+ @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,
36
+ @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,
37
+ ) {}
38
+
39
+ /**
40
+ * JOB-8 — produce the tenant WHERE fragment (or `null` to opt out).
41
+ * Returns `null` when multi-tenancy is off (caller skips the predicate).
42
+ * Throws `MissingTenantIdError` when on + `undefined`.
43
+ * When on + explicit `null`, filters `tenant_id IS NULL`.
44
+ */
45
+ private tenantCondition(
46
+ method: string,
47
+ tenantId: string | null | undefined,
48
+ ) {
49
+ if (!this.multiTenant) return null;
50
+ if (tenantId === undefined) throw new MissingTenantIdError(method);
51
+ return tenantId === null
52
+ ? isNull(jobRuns.tenantId)
53
+ : eq(jobRuns.tenantId, tenantId);
54
+ }
55
+
56
+ async listForScope(
57
+ entityType: string,
58
+ entityId: string,
59
+ opts: ListForScopeOptions = {},
60
+ ): Promise<JobRun[]> {
61
+ const conditions = [
62
+ eq(jobRuns.scopeEntityType, entityType),
63
+ eq(jobRuns.scopeEntityId, entityId),
64
+ ];
65
+ const tenantCond = this.tenantCondition('listForScope', opts.tenantId);
66
+ if (tenantCond) conditions.push(tenantCond);
67
+ if (opts.status) {
68
+ if (Array.isArray(opts.status)) {
69
+ conditions.push(inArray(jobRuns.status, opts.status));
70
+ } else {
71
+ conditions.push(eq(jobRuns.status, opts.status));
72
+ }
73
+ }
74
+ if (opts.jobType) {
75
+ conditions.push(eq(jobRuns.jobType, opts.jobType));
76
+ }
77
+
78
+ const orderCol = (() => {
79
+ switch (opts.orderBy) {
80
+ case 'created_at asc':
81
+ return asc(jobRuns.createdAt);
82
+ case 'run_at desc':
83
+ return desc(jobRuns.runAt);
84
+ case 'run_at asc':
85
+ return asc(jobRuns.runAt);
86
+ case 'created_at desc':
87
+ default:
88
+ return desc(jobRuns.createdAt);
89
+ }
90
+ })();
91
+
92
+ let q = this.db
93
+ .select()
94
+ .from(jobRuns)
95
+ .where(and(...conditions))
96
+ .orderBy(orderCol)
97
+ .$dynamic();
98
+
99
+ if (typeof opts.limit === 'number') {
100
+ q = q.limit(opts.limit);
101
+ }
102
+ if (typeof opts.offset === 'number') {
103
+ q = q.offset(opts.offset);
104
+ }
105
+
106
+ const rows = await q;
107
+ return rows as JobRun[];
108
+ }
109
+
110
+ async cancelForScope(
111
+ entityType: string,
112
+ entityId: string,
113
+ opts: CancelForScopeOptions = {},
114
+ ): Promise<void> {
115
+ const tenantCond = this.tenantCondition('cancelForScope', opts.tenantId);
116
+ const conditions = [
117
+ eq(jobRuns.scopeEntityType, entityType),
118
+ eq(jobRuns.scopeEntityId, entityId),
119
+ inArray(jobRuns.status, NON_TERMINAL_STATUSES),
120
+ ];
121
+ if (tenantCond) conditions.push(tenantCond);
122
+
123
+ const rows = await this.db
124
+ .select({ id: jobRuns.id })
125
+ .from(jobRuns)
126
+ .where(and(...conditions));
127
+
128
+ for (const { id } of rows) {
129
+ // Propagate the tenant gate into cascade-cancel. The scope query has
130
+ // already narrowed to this tenant; passing `tenantId` through keeps
131
+ // the orchestrator's per-row guard consistent under multi-tenant mode.
132
+ await this.orchestrator.cancel(id, {
133
+ cascade: true,
134
+ tenantId: opts.tenantId,
135
+ });
136
+ }
137
+ }
138
+
139
+ async rescheduleForScope(
140
+ entityType: string,
141
+ entityId: string,
142
+ newRunAt: Date,
143
+ opts: RescheduleForScopeOptions = {},
144
+ ): Promise<void> {
145
+ const tenantCond = this.tenantCondition('rescheduleForScope', opts.tenantId);
146
+ const conditions = [
147
+ eq(jobRuns.scopeEntityType, entityType),
148
+ eq(jobRuns.scopeEntityId, entityId),
149
+ eq(jobRuns.status, 'pending'),
150
+ ];
151
+ if (tenantCond) conditions.push(tenantCond);
152
+
153
+ await this.db
154
+ .update(jobRuns)
155
+ .set({ runAt: newRunAt, updatedAt: new Date() })
156
+ .where(and(...conditions));
157
+ }
158
+
159
+ /**
160
+ * Internal helper used by cascade paths (not on the public protocol).
161
+ * Exposed as a public method on the concrete class so infrastructure
162
+ * code (cascade tests, debug tools) can call it without a cast.
163
+ */
164
+ async findByRootRunId(rootRunId: string): Promise<JobRun[]> {
165
+ const rows = await this.db
166
+ .select()
167
+ .from(jobRuns)
168
+ .where(eq(jobRuns.rootRunId, rootRunId));
169
+ return rows as JobRun[];
170
+ }
171
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * MemoryJobRunService — scope-oriented queries and bulk ops over the
3
+ * in-memory run store (ADR-022, JOB-4).
4
+ *
5
+ * Mirrors `DrizzleJobRunService` but scans `MemoryJobStore.runs.values()`.
6
+ * Cancel delegates back to the orchestrator so cascade semantics stay in
7
+ * one place.
8
+ */
9
+ import { Inject, Injectable } from '@nestjs/common';
10
+ import type { JobRunRow } from './job-orchestration.schema';
11
+ import type { JobRun } from './job-orchestrator.protocol';
12
+ import type {
13
+ IJobRunService,
14
+ ListForScopeOptions,
15
+ CancelForScopeOptions,
16
+ RescheduleForScopeOptions,
17
+ } from './job-run-service.protocol';
18
+ import type { IJobOrchestrator } from './job-orchestrator.protocol';
19
+ import { JOB_ORCHESTRATOR, JOBS_MULTI_TENANT } from './jobs-domain.tokens';
20
+ import { MissingTenantIdError } from './jobs-errors';
21
+ import { MemoryJobStore } from './memory-job-store';
22
+
23
+ const NON_TERMINAL_STATUSES: JobRunRow['status'][] = [
24
+ 'pending',
25
+ 'running',
26
+ 'waiting',
27
+ ];
28
+
29
+ @Injectable()
30
+ export class MemoryJobRunService implements IJobRunService {
31
+ constructor(
32
+ private readonly store: MemoryJobStore,
33
+ @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,
34
+ @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,
35
+ ) {}
36
+
37
+ /**
38
+ * JOB-8 — produce a per-row predicate for the tenant gate.
39
+ * Returns `null` when multi-tenancy is off (caller doesn't check).
40
+ * Throws when on + `undefined`; matches `tenant_id IS NULL` on explicit
41
+ * `null` to support cross-tenant background work.
42
+ */
43
+ private tenantPredicate(
44
+ method: string,
45
+ tenantId: string | null | undefined,
46
+ ): ((r: JobRunRow) => boolean) | null {
47
+ if (!this.multiTenant) return null;
48
+ if (tenantId === undefined) throw new MissingTenantIdError(method);
49
+ return (r) => r.tenantId === tenantId;
50
+ }
51
+
52
+ async listForScope(
53
+ entityType: string,
54
+ entityId: string,
55
+ opts: ListForScopeOptions = {},
56
+ ): Promise<JobRun[]> {
57
+ const statusFilter = opts.status
58
+ ? Array.isArray(opts.status)
59
+ ? new Set(opts.status)
60
+ : new Set([opts.status])
61
+ : null;
62
+ const tenantCheck = this.tenantPredicate('listForScope', opts.tenantId);
63
+
64
+ const rows: JobRunRow[] = [];
65
+ for (const r of this.store.runs.values()) {
66
+ if (r.scopeEntityType !== entityType) continue;
67
+ if (r.scopeEntityId !== entityId) continue;
68
+ if (statusFilter && !statusFilter.has(r.status)) continue;
69
+ if (opts.jobType && r.jobType !== opts.jobType) continue;
70
+ if (tenantCheck && !tenantCheck(r)) continue;
71
+ rows.push(r);
72
+ }
73
+
74
+ const orderBy = opts.orderBy ?? 'created_at desc';
75
+ rows.sort((a, b) => compareBy(a, b, orderBy));
76
+
77
+ const offset = opts.offset ?? 0;
78
+ const limit = opts.limit;
79
+ const sliced =
80
+ typeof limit === 'number' ? rows.slice(offset, offset + limit) : rows.slice(offset);
81
+ return sliced as JobRun[];
82
+ }
83
+
84
+ async cancelForScope(
85
+ entityType: string,
86
+ entityId: string,
87
+ opts: CancelForScopeOptions = {},
88
+ ): Promise<void> {
89
+ const tenantCheck = this.tenantPredicate('cancelForScope', opts.tenantId);
90
+
91
+ const ids: string[] = [];
92
+ for (const r of this.store.runs.values()) {
93
+ if (r.scopeEntityType !== entityType) continue;
94
+ if (r.scopeEntityId !== entityId) continue;
95
+ if (!NON_TERMINAL_STATUSES.includes(r.status)) continue;
96
+ if (tenantCheck && !tenantCheck(r)) continue;
97
+ ids.push(r.id);
98
+ }
99
+ for (const id of ids) {
100
+ // Propagate the tenant gate through the orchestrator's cancel so the
101
+ // internal per-row guard passes (no surprise MissingTenantIdError
102
+ // once the scope query has already narrowed to this tenant).
103
+ await this.orchestrator.cancel(id, {
104
+ cascade: true,
105
+ tenantId: opts.tenantId,
106
+ });
107
+ }
108
+ }
109
+
110
+ async rescheduleForScope(
111
+ entityType: string,
112
+ entityId: string,
113
+ newRunAt: Date,
114
+ opts: RescheduleForScopeOptions = {},
115
+ ): Promise<void> {
116
+ const tenantCheck = this.tenantPredicate('rescheduleForScope', opts.tenantId);
117
+ for (const r of this.store.runs.values()) {
118
+ if (r.scopeEntityType !== entityType) continue;
119
+ if (r.scopeEntityId !== entityId) continue;
120
+ if (r.status !== 'pending') continue;
121
+ if (tenantCheck && !tenantCheck(r)) continue;
122
+ this.store.runs.set(r.id, {
123
+ ...r,
124
+ runAt: newRunAt,
125
+ updatedAt: new Date(),
126
+ });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Direct lookup. Not on the protocol — concrete-class convenience for
132
+ * tests. Matches `DrizzleJobRunService.findByRootRunId` in spirit; both
133
+ * are debug / test helpers that sidestep the orchestrator.
134
+ */
135
+ findById(runId: string): JobRun | null {
136
+ return (this.store.runs.get(runId) ?? null) as JobRun | null;
137
+ }
138
+
139
+ /** Public counterpart to the Drizzle backend's `findByRootRunId` helper. */
140
+ findByRootRunId(rootRunId: string): JobRun[] {
141
+ const out: JobRunRow[] = [];
142
+ for (const r of this.store.runs.values()) {
143
+ if (r.rootRunId === rootRunId) out.push(r);
144
+ }
145
+ return out as JobRun[];
146
+ }
147
+ }
148
+
149
+ function compareBy(
150
+ a: JobRunRow,
151
+ b: JobRunRow,
152
+ order: Exclude<ListForScopeOptions['orderBy'], undefined>,
153
+ ): number {
154
+ switch (order) {
155
+ case 'created_at asc':
156
+ return a.createdAt.getTime() - b.createdAt.getTime();
157
+ case 'run_at desc':
158
+ return b.runAt.getTime() - a.runAt.getTime();
159
+ case 'run_at asc':
160
+ return a.runAt.getTime() - b.runAt.getTime();
161
+ case 'created_at desc':
162
+ default:
163
+ return b.createdAt.getTime() - a.createdAt.getTime();
164
+ }
165
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * IJobRunService — scope-oriented queries and bulk operations over
3
+ * `job_run` rows (ADR-022, JOB-2).
4
+ *
5
+ * This is a separate port from `IJobOrchestrator` because the access
6
+ * pattern is different: orchestrator mutates individual runs by id;
7
+ * run service scans by `(scope_entity_type, scope_entity_id)`.
8
+ */
9
+ import type { JobRun } from './job-orchestrator.protocol';
10
+
11
+ export interface ListForScopeOptions {
12
+ /**
13
+ * Single status or set. Widens to the full `JobRun['status']` union so
14
+ * callers can pass values straight from `jobRunStatusEnum`.
15
+ */
16
+ status?: JobRun['status'] | JobRun['status'][];
17
+ jobType?: string;
18
+ limit?: number;
19
+ offset?: number;
20
+ orderBy?:
21
+ | 'created_at desc'
22
+ | 'created_at asc'
23
+ | 'run_at desc'
24
+ | 'run_at asc';
25
+
26
+ /**
27
+ * Multi-tenancy gate (JOB-8). When `multiTenant: true`, the backend adds
28
+ * `AND tenant_id = :tenantId` to the scope query. `undefined` throws
29
+ * `MissingTenantIdError`; explicit `null` matches `tenant_id IS NULL`
30
+ * rows (cross-tenant background work).
31
+ */
32
+ tenantId?: string | null;
33
+ }
34
+
35
+ /**
36
+ * JOB-8 — scoped bulk ops take the same tenant gate as `listForScope`.
37
+ * Added in JOB-8; pre-JOB-8 callers passing nothing continue to compile.
38
+ */
39
+ export interface CancelForScopeOptions {
40
+ tenantId?: string | null;
41
+ }
42
+
43
+ export interface RescheduleForScopeOptions {
44
+ tenantId?: string | null;
45
+ }
46
+
47
+ export interface IJobRunService {
48
+ /**
49
+ * Return runs attached to `(entityType, entityId)`. Backed by
50
+ * `idx_job_run_scope` for efficient reads.
51
+ */
52
+ listForScope(
53
+ entityType: string,
54
+ entityId: string,
55
+ opts?: ListForScopeOptions,
56
+ ): Promise<JobRun[]>;
57
+
58
+ /**
59
+ * Cancel every non-terminal run attached to `(entityType, entityId)`,
60
+ * cascading via `root_run_id`. Used e.g. when an Opportunity is closed
61
+ * and all its background work should stop.
62
+ */
63
+ cancelForScope(
64
+ entityType: string,
65
+ entityId: string,
66
+ opts?: CancelForScopeOptions,
67
+ ): Promise<void>;
68
+
69
+ /**
70
+ * Push `run_at` forward on every `pending` run attached to the scope.
71
+ * Useful for "pause this account's background work until tomorrow".
72
+ */
73
+ rescheduleForScope(
74
+ entityType: string,
75
+ entityId: string,
76
+ newRunAt: Date,
77
+ opts?: RescheduleForScopeOptions,
78
+ ): Promise<void>;
79
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * DrizzleJobStepService — upsert + lookup on `job_step` for replay-safe
3
+ * memoization (ADR-022, JOB-3).
4
+ *
5
+ * `recordStep` upserts on the `(job_run_id, step_id)` unique index — each
6
+ * step row is written as `running` first, then transitioned to a terminal
7
+ * state (`completed` / `failed` / `skipped`). `findStep` is the hot path
8
+ * that `ctx.step()` consults on every invocation; null on miss.
9
+ */
10
+ import { Inject, Injectable } from '@nestjs/common';
11
+ import { and, eq } from 'drizzle-orm';
12
+ import type { DrizzleClient } from '../../types/drizzle';
13
+ import { DRIZZLE } from '../../constants/tokens';
14
+ import { jobSteps, type JobStepRow } from './job-orchestration.schema';
15
+ import type {
16
+ IJobStepService,
17
+ JobStep,
18
+ RecordStepInput,
19
+ } from './job-step-service.protocol';
20
+
21
+ @Injectable()
22
+ export class DrizzleJobStepService implements IJobStepService {
23
+ constructor(@Inject(DRIZZLE) private readonly db: DrizzleClient) {}
24
+
25
+ async recordStep(input: RecordStepInput): Promise<JobStep> {
26
+ const values = {
27
+ jobRunId: input.jobRunId,
28
+ stepId: input.stepId,
29
+ kind: input.kind,
30
+ seq: input.seq,
31
+ status: input.status,
32
+ input: (input.input ?? null) as Record<string, unknown> | null,
33
+ output: (input.output ?? null) as Record<string, unknown> | null,
34
+ error: input.error ?? null,
35
+ attempts: input.attempts ?? 0,
36
+ startedAt: input.startedAt ?? null,
37
+ finishedAt: input.finishedAt ?? null,
38
+ };
39
+
40
+ const [row] = await this.db
41
+ .insert(jobSteps)
42
+ .values(values)
43
+ .onConflictDoUpdate({
44
+ target: [jobSteps.jobRunId, jobSteps.stepId],
45
+ set: {
46
+ status: values.status,
47
+ output: values.output,
48
+ error: values.error,
49
+ finishedAt: values.finishedAt,
50
+ attempts: values.attempts,
51
+ },
52
+ })
53
+ .returning();
54
+
55
+ return row as JobStep;
56
+ }
57
+
58
+ async findStep(runId: string, stepId: string): Promise<JobStep | null> {
59
+ const [row] = await this.db
60
+ .select()
61
+ .from(jobSteps)
62
+ .where(and(eq(jobSteps.jobRunId, runId), eq(jobSteps.stepId, stepId)))
63
+ .limit(1);
64
+ return ((row as JobStepRow | undefined) ?? null) as JobStep | null;
65
+ }
66
+ }