@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.
- package/CHANGELOG.md +6 -0
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +38 -21
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +1 -0
- package/dist/runtime/subsystems/bridge/index.js +29 -12
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/index.js +31 -14
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/index.d.ts +1 -0
- package/dist/runtime/subsystems/jobs/index.js +27 -10
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +9 -4
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-worker.js +6 -2
- package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +27 -10
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -4
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/src/cli/index.js +29 -2
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +2 -1
- package/runtime/analytics/index.ts +31 -0
- package/runtime/analytics/metrics.ts +85 -0
- package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
- package/runtime/analytics/packs/index.ts +5 -0
- package/runtime/analytics/packs/monetary-measures.ts +20 -0
- package/runtime/analytics/specs.ts +54 -0
- package/runtime/analytics/types.ts +105 -0
- package/runtime/base-classes/activity-entity-repository.ts +50 -0
- package/runtime/base-classes/activity-entity-service.ts +48 -0
- package/runtime/base-classes/base-read-use-cases.ts +88 -0
- package/runtime/base-classes/base-repository.ts +289 -0
- package/runtime/base-classes/base-service.ts +183 -0
- package/runtime/base-classes/index.ts +38 -0
- package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
- package/runtime/base-classes/knowledge-entity-service.ts +14 -0
- package/runtime/base-classes/lifecycle-events.ts +152 -0
- package/runtime/base-classes/metadata-entity-repository.ts +80 -0
- package/runtime/base-classes/metadata-entity-service.ts +48 -0
- package/runtime/base-classes/synced-entity-repository.ts +57 -0
- package/runtime/base-classes/synced-entity-service.ts +50 -0
- package/runtime/base-classes/with-analytics.ts +22 -0
- package/runtime/constants/tokens.ts +29 -0
- package/runtime/eav-helpers.ts +74 -0
- package/runtime/pipes/zod-validation.pipe.ts +64 -0
- package/runtime/shared/openapi/error-response.dto.ts +24 -0
- package/runtime/shared/openapi/errors.ts +39 -0
- package/runtime/shared/openapi/index.ts +20 -0
- package/runtime/shared/openapi/registry.tokens.ts +13 -0
- package/runtime/shared/openapi/registry.ts +151 -0
- package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
- package/runtime/subsystems/analytics/analytics.module.ts +64 -0
- package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
- package/runtime/subsystems/analytics/cube-backend.ts +75 -0
- package/runtime/subsystems/analytics/index.ts +15 -0
- package/runtime/subsystems/analytics/noop-backend.ts +27 -0
- package/runtime/subsystems/auth/auth.module.ts +91 -0
- package/runtime/subsystems/auth/auth.tokens.ts +27 -0
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
- package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
- package/runtime/subsystems/auth/index.ts +77 -0
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
- package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
- package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
- package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
- package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
- package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
- package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
- package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
- package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
- package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
- package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
- package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
- package/runtime/subsystems/bridge/bridge.module.ts +160 -0
- package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
- package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
- package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
- package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
- package/runtime/subsystems/bridge/generated/registry.ts +6 -0
- package/runtime/subsystems/bridge/index.ts +84 -0
- package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
- package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
- package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
- package/runtime/subsystems/cache/cache.module.ts +115 -0
- package/runtime/subsystems/cache/cache.protocol.ts +45 -0
- package/runtime/subsystems/cache/cache.schema.ts +27 -0
- package/runtime/subsystems/cache/cache.tokens.ts +17 -0
- package/runtime/subsystems/cache/index.ts +22 -0
- package/runtime/subsystems/events/domain-events.schema.ts +77 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
- package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
- package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
- package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
- package/runtime/subsystems/events/events-errors.ts +30 -0
- package/runtime/subsystems/events/events.module.ts +230 -0
- package/runtime/subsystems/events/events.tokens.ts +62 -0
- package/runtime/subsystems/events/generated/bus.ts +103 -0
- package/runtime/subsystems/events/generated/index.ts +7 -0
- package/runtime/subsystems/events/generated/registry.ts +84 -0
- package/runtime/subsystems/events/generated/schemas.ts +59 -0
- package/runtime/subsystems/events/generated/types.ts +94 -0
- package/runtime/subsystems/events/index.ts +21 -0
- package/runtime/subsystems/index.ts +63 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
- package/runtime/subsystems/jobs/index.ts +120 -0
- package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
- package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +860 -0
- package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
- package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
- package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
- package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +312 -0
- package/runtime/subsystems/jobs/job-worker.ts +624 -0
- package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
- package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
- package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
- package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
- package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
- package/runtime/subsystems/storage/index.ts +18 -0
- package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
- package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
- package/runtime/subsystems/storage/storage.module.ts +60 -0
- package/runtime/subsystems/storage/storage.protocol.ts +78 -0
- package/runtime/subsystems/storage/storage.tokens.ts +9 -0
- package/runtime/subsystems/storage/storage.utils.ts +20 -0
- package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
- package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
- package/runtime/subsystems/sync/index.ts +98 -0
- package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
- package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
- package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
- package/runtime/subsystems/sync/sync-errors.ts +54 -0
- package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
- package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
- package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
- package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
- package/runtime/subsystems/sync/sync.module.ts +156 -0
- package/runtime/subsystems/sync/sync.tokens.ts +57 -0
- package/runtime/types/drizzle.ts +23 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DrizzleJobOrchestrator — Postgres-backed implementation of
|
|
3
|
+
* `IJobOrchestrator` (ADR-022, JOB-3).
|
|
4
|
+
*
|
|
5
|
+
* Single-layer architecture: `start` writes a single `job_run` row; the
|
|
6
|
+
* `JobWorker` polling loop claims it directly via `FOR UPDATE SKIP LOCKED`.
|
|
7
|
+
* No `job_queue` table, no executor port. See `docs/specs/JOB-3.md`.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
11
|
+
import { and, desc, eq, gt, inArray, isNotNull, ne, notInArray, sql } from 'drizzle-orm';
|
|
12
|
+
import type { DrizzleClient } from '../../types/drizzle';
|
|
13
|
+
import type { DrizzleTransaction } from '../events/event-bus.protocol';
|
|
14
|
+
import { DRIZZLE } from '../../constants/tokens';
|
|
15
|
+
import {
|
|
16
|
+
jobRuns,
|
|
17
|
+
jobs,
|
|
18
|
+
type JobDefinitionRow,
|
|
19
|
+
type JobRunRow,
|
|
20
|
+
} from './job-orchestration.schema';
|
|
21
|
+
import type {
|
|
22
|
+
CancelOptions,
|
|
23
|
+
IJobOrchestrator,
|
|
24
|
+
JobPoolDef,
|
|
25
|
+
JobRun,
|
|
26
|
+
JobUpsertEntry,
|
|
27
|
+
StartOptions,
|
|
28
|
+
} from './job-orchestrator.protocol';
|
|
29
|
+
import {
|
|
30
|
+
JobCollisionError,
|
|
31
|
+
JobNotReplayableError,
|
|
32
|
+
JobTemplateFieldMissingError,
|
|
33
|
+
JobTypeNotFoundError,
|
|
34
|
+
MissingTenantIdError,
|
|
35
|
+
} from './jobs-errors';
|
|
36
|
+
import { jobSteps } from './job-orchestration.schema';
|
|
37
|
+
import { JOBS_MULTI_TENANT } from './jobs-domain.tokens';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Terminal statuses — transitions into these are final. Used by `cancel`
|
|
41
|
+
* (to short-circuit idempotently) and by `replay` (as the guard gate).
|
|
42
|
+
*/
|
|
43
|
+
export const TERMINAL_STATUSES = [
|
|
44
|
+
'completed',
|
|
45
|
+
'failed',
|
|
46
|
+
'timed_out',
|
|
47
|
+
'canceled',
|
|
48
|
+
] as const;
|
|
49
|
+
type TerminalStatus = (typeof TERMINAL_STATUSES)[number];
|
|
50
|
+
type JobRunStatus = JobRunRow['status'];
|
|
51
|
+
|
|
52
|
+
/** Statuses excluded from dedupe window matches per ADR-022. */
|
|
53
|
+
const DEDUPE_EXCLUDED_STATUSES: JobRunStatus[] = ['canceled', 'failed'];
|
|
54
|
+
/** Statuses that count as in-flight for concurrency collision checks. */
|
|
55
|
+
const IN_FLIGHT_STATUSES: JobRunStatus[] = ['pending', 'running'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Substitute `{{field}}` placeholders against the input payload.
|
|
59
|
+
*
|
|
60
|
+
* Implementation decision (JOB-3, 2026-04-19): simple `{{field}}` single-key
|
|
61
|
+
* substitution, no dotted paths, no Mustache/Handlebars dependency. A missing
|
|
62
|
+
* field throws `JobTemplateFieldMissingError` synchronously — cheaper than
|
|
63
|
+
* discovering the misconfiguration at claim time.
|
|
64
|
+
*/
|
|
65
|
+
export function evaluateKeyTemplate(
|
|
66
|
+
template: string,
|
|
67
|
+
input: Record<string, unknown>,
|
|
68
|
+
): string {
|
|
69
|
+
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, field: string) => {
|
|
70
|
+
const value = input[field];
|
|
71
|
+
if (value === undefined || value === null) {
|
|
72
|
+
throw new JobTemplateFieldMissingError(template, field);
|
|
73
|
+
}
|
|
74
|
+
return String(value);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Injectable()
|
|
79
|
+
export class DrizzleJobOrchestrator implements IJobOrchestrator {
|
|
80
|
+
// TODO(logging-subsystem): swap to ILogger once ADR-028 lands
|
|
81
|
+
private readonly logger = new Logger(DrizzleJobOrchestrator.name);
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
@Inject(DRIZZLE) private readonly db: DrizzleClient,
|
|
85
|
+
@Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,
|
|
86
|
+
) {}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* JOB-8 — resolve `tenantId` for a mutating / targeted-read call.
|
|
90
|
+
* Returns the tenant value that should be written to the row (or compared
|
|
91
|
+
* against in a WHERE clause). When `multiTenant` is off, the column is
|
|
92
|
+
* forced to `null` regardless of what callers pass. When on, `undefined`
|
|
93
|
+
* throws; `null` and strings pass through untouched.
|
|
94
|
+
*/
|
|
95
|
+
private resolveTenantId(
|
|
96
|
+
method: string,
|
|
97
|
+
tenantId: string | null | undefined,
|
|
98
|
+
): string | null {
|
|
99
|
+
if (!this.multiTenant) return null;
|
|
100
|
+
if (tenantId === undefined) throw new MissingTenantIdError(method);
|
|
101
|
+
return tenantId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ==========================================================================
|
|
105
|
+
// start
|
|
106
|
+
// ==========================================================================
|
|
107
|
+
|
|
108
|
+
async start(
|
|
109
|
+
type: string,
|
|
110
|
+
input: unknown,
|
|
111
|
+
opts: StartOptions = {},
|
|
112
|
+
tx?: DrizzleTransaction,
|
|
113
|
+
): Promise<JobRun> {
|
|
114
|
+
const payload = (input ?? {}) as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
// JOB-8 — resolve tenant gate up front so `multi_tenant=true` +
|
|
117
|
+
// undefined surfaces before any row is touched.
|
|
118
|
+
const tenantId = this.resolveTenantId('start', opts.tenantId);
|
|
119
|
+
|
|
120
|
+
// BRIDGE-7: thread the optional caller tx through every read/write
|
|
121
|
+
// in this method so EventFlowService.publishAndStart can bundle the
|
|
122
|
+
// outbox insert, the eager job_run insert, and (for Case B) the
|
|
123
|
+
// bridge_delivery pre-write into a single transaction.
|
|
124
|
+
const client = (tx ?? this.db) as DrizzleClient;
|
|
125
|
+
|
|
126
|
+
// 1a. Load job definition.
|
|
127
|
+
const [def] = await client
|
|
128
|
+
.select()
|
|
129
|
+
.from(jobs)
|
|
130
|
+
.where(eq(jobs.type, type))
|
|
131
|
+
.limit(1);
|
|
132
|
+
if (!def) throw new JobTypeNotFoundError(type);
|
|
133
|
+
const definition = def as JobDefinitionRow;
|
|
134
|
+
|
|
135
|
+
// 1b. Dedupe check.
|
|
136
|
+
if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
|
|
137
|
+
const dedupeKey = evaluateKeyTemplate(definition.dedupeKeyTemplate, payload);
|
|
138
|
+
const windowStart = new Date(Date.now() - definition.dedupeWindowMs);
|
|
139
|
+
const existing = await client
|
|
140
|
+
.select()
|
|
141
|
+
.from(jobRuns)
|
|
142
|
+
.where(
|
|
143
|
+
and(
|
|
144
|
+
eq(jobRuns.jobType, type),
|
|
145
|
+
eq(jobRuns.dedupeKey, dedupeKey),
|
|
146
|
+
gt(jobRuns.createdAt, windowStart),
|
|
147
|
+
// status NOT IN ('canceled', 'failed')
|
|
148
|
+
notInStatus(DEDUPE_EXCLUDED_STATUSES),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
.orderBy(desc(jobRuns.createdAt))
|
|
152
|
+
.limit(1);
|
|
153
|
+
if (existing.length > 0) {
|
|
154
|
+
return existing[0] as JobRun;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 1c. Concurrency collision check.
|
|
159
|
+
let concurrencyKey: string | null = null;
|
|
160
|
+
if (definition.concurrencyKeyTemplate) {
|
|
161
|
+
concurrencyKey = evaluateKeyTemplate(
|
|
162
|
+
definition.concurrencyKeyTemplate,
|
|
163
|
+
payload,
|
|
164
|
+
);
|
|
165
|
+
const inFlight = await client
|
|
166
|
+
.select()
|
|
167
|
+
.from(jobRuns)
|
|
168
|
+
.where(
|
|
169
|
+
and(
|
|
170
|
+
eq(jobRuns.concurrencyKey, concurrencyKey),
|
|
171
|
+
inArray(jobRuns.status, IN_FLIGHT_STATUSES),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
.limit(1);
|
|
175
|
+
if (inFlight.length > 0) {
|
|
176
|
+
const incumbent = inFlight[0] as JobRun;
|
|
177
|
+
switch (definition.collisionMode) {
|
|
178
|
+
case 'reject':
|
|
179
|
+
throw new JobCollisionError(type, concurrencyKey, incumbent);
|
|
180
|
+
case 'replace':
|
|
181
|
+
// JOB-8 — thread the incumbent's own tenantId through the
|
|
182
|
+
// internal cascade. Without this, every `replace`-collision
|
|
183
|
+
// start() under multiTenant=true throws MissingTenantIdError
|
|
184
|
+
// from the inner cancel() call instead of cancelling the
|
|
185
|
+
// incumbent. Mirrors the memory backend's `cancelLocked(
|
|
186
|
+
// incumbent.id, ..., incumbent.tenantId)` pattern.
|
|
187
|
+
await this.cancel(incumbent.id, {
|
|
188
|
+
cascade: true,
|
|
189
|
+
reason: 'replaced',
|
|
190
|
+
tenantId: incumbent.tenantId,
|
|
191
|
+
});
|
|
192
|
+
break;
|
|
193
|
+
case 'queue':
|
|
194
|
+
// Fall through — row is inserted; claim query gates it until
|
|
195
|
+
// the incumbent transitions (see JobWorker.processRun queue gate).
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 1d. Resolve id + rootRunId, INSERT.
|
|
202
|
+
const newId = randomUUID();
|
|
203
|
+
let rootRunId: string = newId;
|
|
204
|
+
if (opts.parentRunId) {
|
|
205
|
+
const [parent] = await client
|
|
206
|
+
.select({ rootRunId: jobRuns.rootRunId })
|
|
207
|
+
.from(jobRuns)
|
|
208
|
+
.where(eq(jobRuns.id, opts.parentRunId))
|
|
209
|
+
.limit(1);
|
|
210
|
+
if (!parent) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`parentRunId ${opts.parentRunId} does not reference an existing job_run`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
rootRunId = parent.rootRunId;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const dedupeKey =
|
|
219
|
+
definition.dedupeKeyTemplate
|
|
220
|
+
? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload)
|
|
221
|
+
: null;
|
|
222
|
+
|
|
223
|
+
const [inserted] = await client
|
|
224
|
+
.insert(jobRuns)
|
|
225
|
+
.values({
|
|
226
|
+
id: newId,
|
|
227
|
+
jobType: type,
|
|
228
|
+
jobVersion: definition.version,
|
|
229
|
+
parentRunId: opts.parentRunId ?? null,
|
|
230
|
+
rootRunId,
|
|
231
|
+
parentClosePolicy: opts.parentClosePolicy ?? 'terminate',
|
|
232
|
+
scopeEntityType: opts.scope?.entityType ?? null,
|
|
233
|
+
scopeEntityId: opts.scope?.entityId ?? null,
|
|
234
|
+
tenantId,
|
|
235
|
+
tags: opts.tags ?? {},
|
|
236
|
+
pool: opts.pool ?? definition.pool,
|
|
237
|
+
priority: opts.priority ?? definition.priorityDefault,
|
|
238
|
+
concurrencyKey,
|
|
239
|
+
dedupeKey,
|
|
240
|
+
status: 'pending',
|
|
241
|
+
input: payload,
|
|
242
|
+
output: null,
|
|
243
|
+
error: null,
|
|
244
|
+
triggerSource: opts.triggerSource ?? 'manual',
|
|
245
|
+
triggerRef: opts.triggerRef ?? null,
|
|
246
|
+
runAt: opts.runAt ?? new Date(),
|
|
247
|
+
startedAt: null,
|
|
248
|
+
finishedAt: null,
|
|
249
|
+
claimedAt: null,
|
|
250
|
+
attempts: 0,
|
|
251
|
+
})
|
|
252
|
+
.returning();
|
|
253
|
+
|
|
254
|
+
return inserted as JobRun;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ==========================================================================
|
|
258
|
+
// cancel
|
|
259
|
+
// ==========================================================================
|
|
260
|
+
|
|
261
|
+
async cancel(runId: string, opts: CancelOptions = {}): Promise<void> {
|
|
262
|
+
// JOB-8 — resolve tenant gate up front (strict undefined-throws).
|
|
263
|
+
const tenantId = this.resolveTenantId('cancel', opts.tenantId);
|
|
264
|
+
|
|
265
|
+
// Load target.
|
|
266
|
+
const [target] = await this.db
|
|
267
|
+
.select()
|
|
268
|
+
.from(jobRuns)
|
|
269
|
+
.where(eq(jobRuns.id, runId))
|
|
270
|
+
.limit(1);
|
|
271
|
+
if (!target) return;
|
|
272
|
+
// JOB-8 — cross-tenant cancel is a silent no-op (no existence leak).
|
|
273
|
+
if (this.multiTenant && target.tenantId !== tenantId) return;
|
|
274
|
+
if (TERMINAL_STATUSES.includes(target.status as TerminalStatus)) {
|
|
275
|
+
return; // idempotent
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Atomic transition, guarded against concurrent terminal moves.
|
|
279
|
+
const [cancelled] = await this.db
|
|
280
|
+
.update(jobRuns)
|
|
281
|
+
.set({
|
|
282
|
+
status: 'canceled',
|
|
283
|
+
finishedAt: new Date(),
|
|
284
|
+
updatedAt: new Date(),
|
|
285
|
+
})
|
|
286
|
+
.where(
|
|
287
|
+
and(eq(jobRuns.id, runId), notInStatus([...TERMINAL_STATUSES])),
|
|
288
|
+
)
|
|
289
|
+
.returning();
|
|
290
|
+
|
|
291
|
+
if (!cancelled) return; // lost the race; already terminal
|
|
292
|
+
|
|
293
|
+
if (opts.cascade === false) return;
|
|
294
|
+
|
|
295
|
+
// Fetch descendants and branch on parent_close_policy.
|
|
296
|
+
const descendants = await this.db
|
|
297
|
+
.select()
|
|
298
|
+
.from(jobRuns)
|
|
299
|
+
.where(
|
|
300
|
+
and(
|
|
301
|
+
eq(jobRuns.rootRunId, target.rootRunId),
|
|
302
|
+
ne(jobRuns.id, runId),
|
|
303
|
+
notInStatus([...TERMINAL_STATUSES]),
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
for (const child of descendants) {
|
|
308
|
+
const policy = (child as JobRunRow).parentClosePolicy;
|
|
309
|
+
if (policy === 'abandon') continue;
|
|
310
|
+
// 'terminate' | 'cancel' — both transition the child to canceled.
|
|
311
|
+
await this.db
|
|
312
|
+
.update(jobRuns)
|
|
313
|
+
.set({
|
|
314
|
+
status: 'canceled',
|
|
315
|
+
finishedAt: new Date(),
|
|
316
|
+
updatedAt: new Date(),
|
|
317
|
+
})
|
|
318
|
+
.where(
|
|
319
|
+
and(
|
|
320
|
+
eq(jobRuns.id, (child as JobRunRow).id),
|
|
321
|
+
notInStatus([...TERMINAL_STATUSES]),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
void opts.reason; // reserved for future audit logging
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ==========================================================================
|
|
330
|
+
// replay
|
|
331
|
+
// ==========================================================================
|
|
332
|
+
|
|
333
|
+
async replay(runId: string): Promise<JobRun> {
|
|
334
|
+
// Load target + its job definition (we need replay_from).
|
|
335
|
+
const [target] = await this.db
|
|
336
|
+
.select()
|
|
337
|
+
.from(jobRuns)
|
|
338
|
+
.where(eq(jobRuns.id, runId))
|
|
339
|
+
.limit(1);
|
|
340
|
+
if (!target) {
|
|
341
|
+
throw new Error(`replay: run ${runId} not found`);
|
|
342
|
+
}
|
|
343
|
+
const run = target as JobRunRow;
|
|
344
|
+
if (!TERMINAL_STATUSES.includes(run.status as TerminalStatus)) {
|
|
345
|
+
throw new JobNotReplayableError(runId, run.status);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const [def] = await this.db
|
|
349
|
+
.select()
|
|
350
|
+
.from(jobs)
|
|
351
|
+
.where(eq(jobs.type, run.jobType))
|
|
352
|
+
.limit(1);
|
|
353
|
+
if (!def) throw new JobTypeNotFoundError(run.jobType);
|
|
354
|
+
const mode = (def as JobDefinitionRow).replayFrom;
|
|
355
|
+
|
|
356
|
+
// Atomic: step reset + run reset must commit together.
|
|
357
|
+
const result = await this.db.transaction(async (tx) => {
|
|
358
|
+
if (mode === 'scratch') {
|
|
359
|
+
await tx.delete(jobSteps).where(eq(jobSteps.jobRunId, runId));
|
|
360
|
+
} else if (mode === 'last_step') {
|
|
361
|
+
// Delete only non-completed step rows — completed steps stay memoised.
|
|
362
|
+
await tx
|
|
363
|
+
.delete(jobSteps)
|
|
364
|
+
.where(
|
|
365
|
+
and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, 'completed')),
|
|
366
|
+
);
|
|
367
|
+
} else {
|
|
368
|
+
// 'last_checkpoint' — Phase 1 has no explicit checkpoint markers, so
|
|
369
|
+
// behaviour collapses to `last_step`. See docs/specs/JOB-3.md
|
|
370
|
+
// "Implementation Decisions" — planned divergence in a later phase.
|
|
371
|
+
await tx
|
|
372
|
+
.delete(jobSteps)
|
|
373
|
+
.where(
|
|
374
|
+
and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, 'completed')),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const [updated] = await tx
|
|
379
|
+
.update(jobRuns)
|
|
380
|
+
.set({
|
|
381
|
+
status: 'pending',
|
|
382
|
+
attempts: 0,
|
|
383
|
+
runAt: new Date(),
|
|
384
|
+
startedAt: null,
|
|
385
|
+
finishedAt: null,
|
|
386
|
+
claimedAt: null,
|
|
387
|
+
error: null,
|
|
388
|
+
output: null,
|
|
389
|
+
updatedAt: new Date(),
|
|
390
|
+
})
|
|
391
|
+
.where(eq(jobRuns.id, runId))
|
|
392
|
+
.returning();
|
|
393
|
+
return updated as JobRunRow;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return result as JobRun;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ==========================================================================
|
|
400
|
+
// upsertJobRows — boot-time materialisation of `job` definitions
|
|
401
|
+
// ==========================================================================
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Hash-gated `INSERT … ON CONFLICT (type) DO UPDATE … WHERE` per Q3
|
|
405
|
+
* resolution (2026-04-19): the `UPDATE` branch executes only when one
|
|
406
|
+
* of the persisted metadata fields differs from the incoming payload;
|
|
407
|
+
* `version` bumps only on real change; concurrent boots with identical
|
|
408
|
+
* content are idempotent no-ops.
|
|
409
|
+
*
|
|
410
|
+
* Why this shape (not `DO NOTHING`, not advisory locks):
|
|
411
|
+
* - `DO NOTHING` would let an old-version instance leave a stale row
|
|
412
|
+
* that a new-version instance can't overwrite during a rolling deploy.
|
|
413
|
+
* - Advisory locks add latency and leak risk under crashes.
|
|
414
|
+
* - The `WHERE … IS DISTINCT FROM …` clause makes the conditional
|
|
415
|
+
* atomic — no read-modify-write race on `version` between concurrent
|
|
416
|
+
* boots.
|
|
417
|
+
*
|
|
418
|
+
* Orphan detection: a single `SELECT type FROM job WHERE type NOT IN (...)`
|
|
419
|
+
* returns the types present in DB but absent from `entries`. Caller (boot
|
|
420
|
+
* validator) decides whether to throw `BootValidationError`.
|
|
421
|
+
*/
|
|
422
|
+
async upsertJobRows(
|
|
423
|
+
entries: JobUpsertEntry[],
|
|
424
|
+
poolConfig: ReadonlyMap<string, JobPoolDef>,
|
|
425
|
+
): Promise<{ orphaned: string[] }> {
|
|
426
|
+
void poolConfig; // pool validation is the module's responsibility; orchestrator just persists
|
|
427
|
+
|
|
428
|
+
for (const entry of entries) {
|
|
429
|
+
const meta = entry.meta;
|
|
430
|
+
const pool = meta.pool ?? 'batch';
|
|
431
|
+
const retryPolicy = meta.retry ?? {
|
|
432
|
+
attempts: 1,
|
|
433
|
+
backoff: 'fixed' as const,
|
|
434
|
+
baseMs: 0,
|
|
435
|
+
};
|
|
436
|
+
const concurrencyKeyTemplate =
|
|
437
|
+
(meta.concurrency as { key?: unknown } | undefined)?.key;
|
|
438
|
+
const concurrencyKeyTemplateStr =
|
|
439
|
+
typeof concurrencyKeyTemplate === 'string' ? concurrencyKeyTemplate : null;
|
|
440
|
+
const collisionMode =
|
|
441
|
+
(meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??
|
|
442
|
+
'queue';
|
|
443
|
+
const dedupeKeyTemplate =
|
|
444
|
+
(meta.dedupe as { key?: unknown } | undefined)?.key;
|
|
445
|
+
const dedupeKeyTemplateStr =
|
|
446
|
+
typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null;
|
|
447
|
+
const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
|
|
448
|
+
const timeoutMs = meta.timeoutMs ?? null;
|
|
449
|
+
const replayFrom = meta.replayFrom ?? 'last_checkpoint';
|
|
450
|
+
const scopeEntityType = meta.scope?.entity ?? null;
|
|
451
|
+
// Q3 resolution: priority_default and replay_from are part of the
|
|
452
|
+
// hashed metadata even though they aren't currently set via decorator
|
|
453
|
+
// metadata above (priority_default has no `@JobHandler` field yet).
|
|
454
|
+
// Default to 0 to keep UPDATE branch quiet across deploys.
|
|
455
|
+
const priorityDefault = 0;
|
|
456
|
+
|
|
457
|
+
// Hash-gated upsert: every metadata column appears in the WHERE clause
|
|
458
|
+
// so the UPDATE branch only fires on a real change. `version` bumps
|
|
459
|
+
// exactly when the WHERE matches.
|
|
460
|
+
await this.db
|
|
461
|
+
.insert(jobs)
|
|
462
|
+
.values({
|
|
463
|
+
type: entry.type,
|
|
464
|
+
version: 1,
|
|
465
|
+
pool,
|
|
466
|
+
scopeEntityType,
|
|
467
|
+
retryPolicy,
|
|
468
|
+
timeoutMs,
|
|
469
|
+
concurrencyKeyTemplate: concurrencyKeyTemplateStr,
|
|
470
|
+
collisionMode,
|
|
471
|
+
dedupeKeyTemplate: dedupeKeyTemplateStr,
|
|
472
|
+
dedupeWindowMs,
|
|
473
|
+
priorityDefault,
|
|
474
|
+
replayFrom,
|
|
475
|
+
})
|
|
476
|
+
.onConflictDoUpdate({
|
|
477
|
+
target: jobs.type,
|
|
478
|
+
set: {
|
|
479
|
+
pool: sql`EXCLUDED.pool`,
|
|
480
|
+
scopeEntityType: sql`EXCLUDED.scope_entity_type`,
|
|
481
|
+
retryPolicy: sql`EXCLUDED.retry_policy`,
|
|
482
|
+
timeoutMs: sql`EXCLUDED.timeout_ms`,
|
|
483
|
+
concurrencyKeyTemplate: sql`EXCLUDED.concurrency_key_template`,
|
|
484
|
+
collisionMode: sql`EXCLUDED.collision_mode`,
|
|
485
|
+
dedupeKeyTemplate: sql`EXCLUDED.dedupe_key_template`,
|
|
486
|
+
dedupeWindowMs: sql`EXCLUDED.dedupe_window_ms`,
|
|
487
|
+
priorityDefault: sql`EXCLUDED.priority_default`,
|
|
488
|
+
replayFrom: sql`EXCLUDED.replay_from`,
|
|
489
|
+
version: sql`${jobs.version} + 1`,
|
|
490
|
+
updatedAt: sql`now()`,
|
|
491
|
+
},
|
|
492
|
+
// The hash gate: every field listed in the Q3 resolution appears
|
|
493
|
+
// here. `IS DISTINCT FROM` is the null-safe inequality operator;
|
|
494
|
+
// jsonb cast to text gives stable comparison without invoking a
|
|
495
|
+
// dedicated hash column (avoids a JOB-1 schema migration).
|
|
496
|
+
setWhere: sql`
|
|
497
|
+
${jobs.pool} IS DISTINCT FROM EXCLUDED.pool OR
|
|
498
|
+
${jobs.retryPolicy}::text IS DISTINCT FROM EXCLUDED.retry_policy::text OR
|
|
499
|
+
${jobs.timeoutMs} IS DISTINCT FROM EXCLUDED.timeout_ms OR
|
|
500
|
+
${jobs.concurrencyKeyTemplate} IS DISTINCT FROM EXCLUDED.concurrency_key_template OR
|
|
501
|
+
${jobs.collisionMode} IS DISTINCT FROM EXCLUDED.collision_mode OR
|
|
502
|
+
${jobs.dedupeKeyTemplate} IS DISTINCT FROM EXCLUDED.dedupe_key_template OR
|
|
503
|
+
${jobs.dedupeWindowMs} IS DISTINCT FROM EXCLUDED.dedupe_window_ms OR
|
|
504
|
+
${jobs.priorityDefault} IS DISTINCT FROM EXCLUDED.priority_default OR
|
|
505
|
+
${jobs.replayFrom} IS DISTINCT FROM EXCLUDED.replay_from OR
|
|
506
|
+
${jobs.scopeEntityType} IS DISTINCT FROM EXCLUDED.scope_entity_type
|
|
507
|
+
`,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Orphan detection: any `job` row whose type is not in the registry.
|
|
512
|
+
const types = entries.map((e) => e.type);
|
|
513
|
+
const orphans =
|
|
514
|
+
types.length === 0
|
|
515
|
+
? await this.db.select({ type: jobs.type }).from(jobs)
|
|
516
|
+
: await this.db
|
|
517
|
+
.select({ type: jobs.type })
|
|
518
|
+
.from(jobs)
|
|
519
|
+
.where(notInArray(jobs.type, types));
|
|
520
|
+
|
|
521
|
+
return { orphaned: orphans.map((o) => o.type) };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
function notInStatus(statuses: JobRunStatus[]) {
|
|
528
|
+
// Drizzle's inArray composes with `not` via negation helper; use raw sql
|
|
529
|
+
// to stay readable. `inArray` + `.not()` isn't idiomatic in 0.45.
|
|
530
|
+
const negated = statuses.map((s) => ne(jobRuns.status, s));
|
|
531
|
+
return and(...negated);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// `isNotNull` + `gt` imports are retained for potential future use; silence
|
|
535
|
+
// unused-import lint by re-exporting via `void`.
|
|
536
|
+
void isNotNull;
|