@voyantjs/workflows-orchestrator-node 0.28.3 → 0.30.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.
@@ -0,0 +1,491 @@
1
+ // Mode 2 driver — pure Node, Postgres-backed.
2
+ //
3
+ // Returns a `DriverFactory` (per architecture doc §6.3) that the framework
4
+ // invokes after `createApp()` has assembled its `ModuleContainer`. Composes:
5
+ //
6
+ // * `createPostgresRunRecordStore` — primary state, against
7
+ // `voyant_snapshot_runs.run_record` JSONB.
8
+ // * `createPostgresManifestStore` — manifest history, against
9
+ // `voyant_workflow_manifests`.
10
+ // * In-process step handler glued to `handleStepRequest` from
11
+ // `@voyantjs/workflows/handler` — the workflow body executes in the
12
+ // same Node process as the driver.
13
+ //
14
+ // The Postgres time wheel (`createPersistentWakeupManager`) is started via
15
+ // the returned driver's `start()` lifecycle helper or — when used from
16
+ // `createApp()` — by the framework's bootstrap.
17
+ //
18
+ // See architecture doc §7 for the full Mode 2 design.
19
+
20
+ import type {
21
+ ListRunsOptions,
22
+ Run,
23
+ RunDetail,
24
+ RunSummary,
25
+ TriggerOptions,
26
+ } from "@voyantjs/workflows"
27
+ import type {
28
+ DriverFactory,
29
+ DriverFactoryDeps,
30
+ IngestEventArgs,
31
+ IngestEventResponse,
32
+ IngestMatch,
33
+ WorkflowAdmin,
34
+ WorkflowDriver,
35
+ } from "@voyantjs/workflows/driver"
36
+ import { deriveStableEventId } from "@voyantjs/workflows/events"
37
+ import { handleStepRequest, type WorkflowStepRequest } from "@voyantjs/workflows/handler"
38
+ import type { WorkflowManifest } from "@voyantjs/workflows/protocol"
39
+ import {
40
+ cancel as orchestratorCancel,
41
+ trigger as orchestratorTrigger,
42
+ type RunRecord,
43
+ routeEvent,
44
+ type StepHandler,
45
+ } from "@voyantjs/workflows-orchestrator"
46
+ import type { drizzle } from "drizzle-orm/node-postgres"
47
+
48
+ import {
49
+ createPersistentWakeupManager,
50
+ type PersistentWakeupManager,
51
+ } from "./persistent-wakeup-manager.js"
52
+ import { createPostgresManifestStore } from "./postgres-manifest-store.js"
53
+ import { createPostgresRunRecordStore } from "./postgres-run-record-store.js"
54
+ import { createPostgresWakeupStore } from "./postgres-wakeup-store.js"
55
+ import { syncWakeupFromRecord, type WakeupStore } from "./wakeup-store.js"
56
+
57
+ type Db = ReturnType<typeof drizzle>
58
+
59
+ // ---- Public factory options ----
60
+
61
+ export interface NodeStandaloneDriverOptions {
62
+ /** Long-lived Postgres connection (drizzle-orm `node-postgres` adapter). */
63
+ db: Db
64
+ /** Default environment for `trigger()` calls that don't specify one. */
65
+ defaultEnvironment?: "production" | "preview" | "development"
66
+ /** Tenant metadata stamped onto every triggered run. */
67
+ tenantMeta?: RunRecord["tenantMeta"]
68
+ /** Injectable clock; defaults to `Date.now`. */
69
+ now?: () => number
70
+ /**
71
+ * Step handler override. Defaults to in-process `handleStepRequest`
72
+ * with the framework-supplied `services` container plumbed through
73
+ * (so step bodies can resolve via `ctx.services.resolve(...)`).
74
+ */
75
+ handler?: StepHandler
76
+ /**
77
+ * Latest N manifest versions to retain per environment after each
78
+ * registerManifest. Defaults to 3 (per architecture doc §14.2). Set to
79
+ * a high number to disable pruning effectively.
80
+ */
81
+ manifestVersionsToKeep?: number
82
+ /**
83
+ * Time-wheel poll interval, ms. The wakeup manager (architecture doc
84
+ * §7.2) polls `voyant_wakeups` for due alarms and resumes parked runs
85
+ * via the orchestrator. Defaults to 1_000 ms. Lower values reduce
86
+ * sleep-resume latency at the cost of DB load.
87
+ */
88
+ wakeupPollIntervalMs?: number
89
+ /**
90
+ * Wakeup lease TTL, ms. A poll instance leases a due wakeup for this
91
+ * long; if the process dies mid-process, another instance picks the
92
+ * wakeup back up after the lease expires. Defaults to 4× the poll
93
+ * interval (or 5_000 ms, whichever is greater).
94
+ */
95
+ wakeupLeaseMs?: number
96
+ /**
97
+ * Lease owner identifier. Used to disambiguate poller instances
98
+ * across processes. Defaults to a random per-driver token.
99
+ */
100
+ wakeupLeaseOwner?: string
101
+ /**
102
+ * When `true`, the wakeup poller does NOT auto-start on construction.
103
+ * Callers must invoke the returned driver's lifecycle hooks
104
+ * themselves — useful for tests that want to control the poll
105
+ * cadence. Defaults to `false` (poller starts immediately).
106
+ */
107
+ disableTimeWheel?: boolean
108
+ }
109
+
110
+ const DEFAULT_TENANT_META: RunRecord["tenantMeta"] = {
111
+ tenantId: "default",
112
+ projectId: "default",
113
+ organizationId: "default",
114
+ }
115
+
116
+ const DEFAULT_MANIFEST_KEEP = 3
117
+
118
+ /**
119
+ * Build the Mode 2 driver factory. The factory closes over its options
120
+ * and returns a fresh `WorkflowDriver` when `createApp()` (or a test)
121
+ * calls it with `DriverFactoryDeps`.
122
+ *
123
+ * Usage:
124
+ *
125
+ * createApp({
126
+ * workflows: {
127
+ * driver: createNodeStandaloneDriver({ db, defaultEnvironment: "production" }),
128
+ * },
129
+ * })
130
+ *
131
+ * Or in compliance tests:
132
+ *
133
+ * const driver = createNodeStandaloneDriver({ db: testDb })(testFactoryDeps())
134
+ */
135
+ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): DriverFactory {
136
+ return (deps: DriverFactoryDeps): WorkflowDriver => {
137
+ const runStore = createPostgresRunRecordStore({ db: opts.db })
138
+ const manifestStore = createPostgresManifestStore({ db: opts.db })
139
+ const wakeupStore: WakeupStore = createPostgresWakeupStore({ db: opts.db })
140
+ const now = opts.now ?? deps.now ?? (() => Date.now())
141
+ const tenantMeta = opts.tenantMeta ?? DEFAULT_TENANT_META
142
+ const defaultEnv = opts.defaultEnvironment ?? "development"
143
+ const keep = opts.manifestVersionsToKeep ?? DEFAULT_MANIFEST_KEEP
144
+ const leaseOwner = opts.wakeupLeaseOwner ?? `node-standalone-${randomToken()}`
145
+
146
+ // Wire the framework-supplied service container through to step bodies.
147
+ // The handler closes over `deps.services` so every step invocation
148
+ // surfaces it as `ctx.services` inside the workflow body.
149
+ const handler: StepHandler =
150
+ opts.handler ??
151
+ (async (req: WorkflowStepRequest, stepOpts) =>
152
+ handleStepRequest(req, { services: deps.services }, stepOpts))
153
+
154
+ // Persistent wakeup manager — polls `voyant_wakeups` for runs
155
+ // parked on DATETIME waitpoints and resumes them via the orchestrator's
156
+ // `resumeDueAlarms`. This is what makes `ctx.sleep(...)` actually
157
+ // wake up in Mode 2 (architecture doc §7.2).
158
+ const wakeupManager: PersistentWakeupManager<RunRecord> = createPersistentWakeupManager({
159
+ wakeupStore,
160
+ handler,
161
+ leaseOwner,
162
+ leaseMs: opts.wakeupLeaseMs,
163
+ intervalMs: opts.wakeupPollIntervalMs,
164
+ now,
165
+ logger: (level, message, data) => deps.logger(level, message, data),
166
+ // For Mode 2 the "stored" representation IS the RunRecord — the
167
+ // postgres-run-record-store carries the full state on `run_record`
168
+ // JSONB. So toRecord/fromRecord are identity.
169
+ async getRun(runId) {
170
+ return runStore.get(runId)
171
+ },
172
+ async saveRun(record) {
173
+ await runStore.save(record)
174
+ return record
175
+ },
176
+ toRecord: (record) => record,
177
+ fromRecord: (record) => record,
178
+ async listRuns() {
179
+ // Bootstrap-time list of currently-parked runs to seed the wakeup
180
+ // store. Mode 2 uses status="waiting" filter on the run-record store.
181
+ return runStore.list({ status: "waiting" })
182
+ },
183
+ })
184
+
185
+ if (!opts.disableTimeWheel) {
186
+ // Auto-start the poller. Callers can opt out via `disableTimeWheel`
187
+ // for tests that want to control the cadence manually (poll explicitly
188
+ // via `manager.poll()`).
189
+ wakeupManager.start()
190
+ }
191
+
192
+ let shuttingDown = false
193
+
194
+ // ---- WorkflowDriver implementation ----
195
+
196
+ async function registerManifest(args: {
197
+ environment: WorkflowDriver["registerManifest"] extends (a: infer A) => unknown
198
+ ? A extends { environment: infer E }
199
+ ? E
200
+ : never
201
+ : never
202
+ manifest: WorkflowManifest
203
+ }): Promise<{ versionId: string }> {
204
+ assertNotShutdown(shuttingDown)
205
+ const result = await manifestStore.registerManifest({
206
+ environment: args.environment as string,
207
+ versionId: args.manifest.versionId,
208
+ manifest: args.manifest as unknown as Record<string, unknown>,
209
+ })
210
+ // Best-effort prune; failures here shouldn't fail boot.
211
+ try {
212
+ await manifestStore.pruneToVersions(args.environment as string, keep)
213
+ } catch (err) {
214
+ deps.logger("warn", "manifest prune failed (non-fatal)", {
215
+ environment: args.environment,
216
+ error: err instanceof Error ? err.message : String(err),
217
+ })
218
+ }
219
+ return result
220
+ }
221
+
222
+ async function getManifest(args: { environment: string }): Promise<WorkflowManifest | null> {
223
+ const envelope = await manifestStore.getCurrent(args.environment)
224
+ if (!envelope) return null
225
+ return envelope.manifest as unknown as WorkflowManifest
226
+ }
227
+
228
+ async function trigger<TIn, TOut>(
229
+ workflow: { id: string } | string,
230
+ input: TIn,
231
+ triggerOpts?: TriggerOptions,
232
+ ): Promise<Run<TOut>> {
233
+ assertNotShutdown(shuttingDown)
234
+ const workflowId = typeof workflow === "string" ? workflow : workflow.id
235
+ const env = triggerOpts?.environment ?? defaultEnv
236
+
237
+ const record = await orchestratorTrigger(
238
+ {
239
+ workflowId,
240
+ workflowVersion: triggerOpts?.lockToVersion ?? "v1",
241
+ input: input as unknown,
242
+ tenantMeta,
243
+ environment: env,
244
+ tags: triggerOpts?.tags,
245
+ idempotencyKey: triggerOpts?.idempotencyKey,
246
+ },
247
+ { store: runStore, handler, now },
248
+ )
249
+ // Sync wakeup row so the time-wheel can resume DATETIME-parked runs.
250
+ // No-op if the run completed inline (status !== "waiting").
251
+ await syncWakeupFromRecord(wakeupStore, record)
252
+ return runRecordToRun<TOut>(record)
253
+ }
254
+
255
+ async function ingestEvent(args: IngestEventArgs): Promise<IngestEventResponse> {
256
+ assertNotShutdown(shuttingDown)
257
+ const stored = await manifestStore.getCurrent(args.environment)
258
+ if (!stored) {
259
+ return {
260
+ ok: false,
261
+ reason: "manifest_not_registered",
262
+ message: `No manifest is registered for environment "${args.environment}".`,
263
+ }
264
+ }
265
+ const eventId = await ensureEventId(args.envelope)
266
+ const manifest = stored.manifest as unknown as WorkflowManifest
267
+ const routed = routeEvent({
268
+ manifest,
269
+ envelope: args.envelope,
270
+ eventId,
271
+ idempotencyOverride: args.idempotencyKey,
272
+ })
273
+
274
+ const matches: IngestMatch[] = []
275
+ let anyTriggered = false
276
+ let anyFailed = false
277
+ for (const entry of routed) {
278
+ if (entry.status === "skipped") {
279
+ matches.push({
280
+ filterId: entry.filterId,
281
+ status: "skipped",
282
+ reason: entry.reason,
283
+ details: entry.details,
284
+ })
285
+ continue
286
+ }
287
+ try {
288
+ const record = await orchestratorTrigger(
289
+ {
290
+ workflowId: entry.targetWorkflowId,
291
+ workflowVersion: "v1",
292
+ input: entry.input,
293
+ tenantMeta,
294
+ environment: args.environment,
295
+ idempotencyKey: entry.idempotencyKey,
296
+ triggeredBy: {
297
+ kind: "event",
298
+ eventId,
299
+ eventType: args.envelope.name,
300
+ filterId: entry.filterId,
301
+ },
302
+ },
303
+ { store: runStore, handler, now },
304
+ )
305
+ await syncWakeupFromRecord(wakeupStore, record)
306
+ matches.push({
307
+ filterId: entry.filterId,
308
+ targetWorkflowId: entry.targetWorkflowId,
309
+ runId: record.id,
310
+ idempotencyKey: entry.idempotencyKey,
311
+ status: "queued",
312
+ })
313
+ anyTriggered = true
314
+ } catch (err) {
315
+ matches.push({
316
+ filterId: entry.filterId,
317
+ targetWorkflowId: entry.targetWorkflowId,
318
+ status: "error",
319
+ reason: err instanceof Error ? err.message : String(err),
320
+ })
321
+ anyFailed = true
322
+ }
323
+ }
324
+
325
+ if (matches.length > 0 && !anyTriggered && anyFailed) {
326
+ return {
327
+ ok: false,
328
+ reason: "trigger_failed_for_all_matches",
329
+ message: "every matched filter failed to trigger",
330
+ }
331
+ }
332
+ return { ok: true, eventId, matches }
333
+ }
334
+
335
+ async function shutdown(): Promise<void> {
336
+ shuttingDown = true
337
+ // Stop the time-wheel poller so the process can exit cleanly.
338
+ // Idempotent — calling stop() on an already-stopped manager is a
339
+ // no-op.
340
+ wakeupManager.stop()
341
+ }
342
+
343
+ // ---- WorkflowAdmin (full; Mode 2 has Postgres-native query support) ----
344
+
345
+ const admin: WorkflowAdmin = {
346
+ async listRuns(listOpts?: ListRunsOptions) {
347
+ const filterStatus = normalizeStatusFilter(listOpts?.status)
348
+ const filterEnv = listOpts?.environment
349
+ const filterWorkflow = listOpts?.workflowId
350
+ const filterTag = listOpts?.tag
351
+ const filterSince = toEpoch(listOpts?.since)
352
+ const filterUntil = toEpoch(listOpts?.until)
353
+ const limit = listOpts?.limit ?? 100
354
+
355
+ // Take a generous fetch window; in-memory filter for fields the
356
+ // store doesn't index (env, tag, since/until). For real load we'd
357
+ // push these down into the query — out of scope for PR1 step 6.
358
+ const records = await runStore.list({
359
+ workflowId: filterWorkflow,
360
+ status: filterStatus?.[0] as never,
361
+ limit: limit * 2,
362
+ })
363
+
364
+ const results: RunSummary[] = []
365
+ for (const rec of records) {
366
+ if (filterStatus && !filterStatus.includes(rec.status as never)) continue
367
+ if (filterEnv && rec.environment !== filterEnv) continue
368
+ if (filterTag && !rec.tags.includes(filterTag)) continue
369
+ if (filterSince !== undefined && rec.startedAt < filterSince) continue
370
+ if (filterUntil !== undefined && rec.startedAt > filterUntil) continue
371
+ results.push(runRecordToSummary(rec))
372
+ }
373
+ const page = results.slice(0, limit)
374
+ const nextCursor = results.length > limit ? String(limit) : undefined
375
+ return { runs: page, nextCursor }
376
+ },
377
+
378
+ async getRun(runId: string): Promise<RunDetail | null> {
379
+ const rec = await runStore.get(runId)
380
+ return rec ? runRecordToDetail(rec) : null
381
+ },
382
+
383
+ async cancelRun(runId: string, cancelOpts?: { reason?: string; compensate?: boolean }) {
384
+ // The orchestrator core's cancel() does NOT run compensations by
385
+ // default (architecture doc §21.21). The `compensate` flag is
386
+ // accepted but no-ops in v1.
387
+ void cancelOpts?.compensate
388
+ await orchestratorCancel(
389
+ { runId, reason: cancelOpts?.reason },
390
+ { store: runStore, handler, now },
391
+ )
392
+ },
393
+
394
+ streamRun(runId: string): AsyncIterable<never> {
395
+ // Live journal-event streaming is a follow-up — needs LISTEN/NOTIFY
396
+ // wired against the run store or a polling source. PR1 ships
397
+ // listRuns + getRun; streamRun returns an immediately-exhausted
398
+ // iterable so dashboards probing it get a clean empty stream
399
+ // instead of an undefined.
400
+ void runId
401
+ return {
402
+ [Symbol.asyncIterator]() {
403
+ return {
404
+ next: async () => ({ value: undefined as never, done: true as const }),
405
+ }
406
+ },
407
+ }
408
+ },
409
+ }
410
+
411
+ return {
412
+ registerManifest,
413
+ trigger,
414
+ ingestEvent,
415
+ getManifest,
416
+ shutdown,
417
+ admin,
418
+ }
419
+ }
420
+ }
421
+
422
+ // ---- Helpers ----
423
+
424
+ function assertNotShutdown(shuttingDown: boolean): void {
425
+ if (shuttingDown) {
426
+ throw new Error("NodeStandaloneDriver: shutdown() has been called; new operations are refused.")
427
+ }
428
+ }
429
+
430
+ function randomToken(): string {
431
+ return Math.floor(Math.random() * 1_000_000_000)
432
+ .toString(36)
433
+ .padStart(6, "0")
434
+ }
435
+
436
+ async function ensureEventId(envelope: {
437
+ name: string
438
+ data: unknown
439
+ metadata?: { eventId?: string }
440
+ emittedAt: string
441
+ }): Promise<string> {
442
+ if (envelope.metadata?.eventId) return envelope.metadata.eventId
443
+ // Content-derived fallback per architecture doc §15.2 — closes the
444
+ // dedup hole reviewer P2.2 flagged.
445
+ return deriveStableEventId(envelope)
446
+ }
447
+
448
+ function runRecordToRun<TOut>(rec: RunRecord): Run<TOut> {
449
+ return {
450
+ id: rec.id,
451
+ workflowId: rec.workflowId,
452
+ status: rec.status as Run["status"],
453
+ startedAt: rec.startedAt,
454
+ }
455
+ }
456
+
457
+ function runRecordToSummary(rec: RunRecord): RunSummary {
458
+ return {
459
+ id: rec.id,
460
+ workflowId: rec.workflowId,
461
+ status: rec.status as RunSummary["status"],
462
+ startedAt: rec.startedAt,
463
+ completedAt: rec.completedAt,
464
+ tags: [...rec.tags],
465
+ environment: rec.environment,
466
+ }
467
+ }
468
+
469
+ function runRecordToDetail(rec: RunRecord): RunDetail {
470
+ return {
471
+ ...runRecordToSummary(rec),
472
+ version: rec.workflowVersion,
473
+ input: rec.input,
474
+ output: rec.output,
475
+ error: rec.error,
476
+ durationMs:
477
+ rec.completedAt !== undefined ? Math.max(0, rec.completedAt - rec.startedAt) : undefined,
478
+ }
479
+ }
480
+
481
+ function normalizeStatusFilter(
482
+ s: ListRunsOptions["status"] | undefined,
483
+ ): readonly string[] | undefined {
484
+ if (s === undefined) return undefined
485
+ return Array.isArray(s) ? s : [s]
486
+ }
487
+
488
+ function toEpoch(v: number | Date | undefined): number | undefined {
489
+ if (v === undefined) return undefined
490
+ return typeof v === "number" ? v : v.getTime()
491
+ }
@@ -0,0 +1,144 @@
1
+ // Postgres-backed manifest store. Holds the serialized WorkflowManifest
2
+ // pushed at `createApp()` boot via `driver.registerManifest(...)` and read
3
+ // by `driver.getManifest(...)` for boot-time mismatch detection and the
4
+ // dashboard's filter inspector.
5
+ //
6
+ // One row is "current" per environment, enforced by the partial unique
7
+ // index `voyant_workflow_manifests_current_idx` (migration 0004). History
8
+ // is retained — `pruneToVersions(n)` keeps the latest N per environment.
9
+ //
10
+ // See architecture doc §14 for the manifest lifecycle.
11
+
12
+ import { and, desc, eq, ne, sql } from "drizzle-orm"
13
+ import type { drizzle } from "drizzle-orm/node-postgres"
14
+
15
+ import { workflowManifestsTable } from "./postgres-schema.js"
16
+
17
+ type ManifestDb = ReturnType<typeof drizzle>
18
+
19
+ /**
20
+ * Structural view of `WorkflowManifest` (from `@voyantjs/workflows/protocol`).
21
+ * Declared locally to avoid pulling the workflows package's protocol export
22
+ * into this store — every consumer satisfies the shape via TypeScript
23
+ * structural compat, same pattern Voyant uses elsewhere.
24
+ */
25
+ export interface ManifestEnvelope {
26
+ environment: string
27
+ versionId: string
28
+ manifest: Record<string, unknown>
29
+ }
30
+
31
+ export interface ManifestStore {
32
+ /**
33
+ * Idempotent. Same `(environment, versionId)` returns without re-write.
34
+ * New `versionId` for an existing environment marks the new row
35
+ * `is_current = true` and the previous current `is_current = false`.
36
+ */
37
+ registerManifest(envelope: ManifestEnvelope): Promise<{ versionId: string }>
38
+
39
+ /** Returns the current manifest for the environment, or null. */
40
+ getCurrent(environment: string): Promise<ManifestEnvelope | null>
41
+
42
+ /** Retain the latest `keep` versions per environment; delete older. */
43
+ pruneToVersions(environment: string, keep: number): Promise<{ deleted: number }>
44
+ }
45
+
46
+ export interface PostgresManifestStoreOptions {
47
+ db: ManifestDb
48
+ }
49
+
50
+ export function createPostgresManifestStore(opts: PostgresManifestStoreOptions): ManifestStore {
51
+ const db = opts.db
52
+
53
+ return {
54
+ async registerManifest(envelope) {
55
+ // Atomically: insert new row, flip is_current to false on every other
56
+ // row for this environment, mark this row is_current = true. Single
57
+ // transaction so concurrent registrations don't leave two rows current.
58
+ await db.transaction(async (tx) => {
59
+ await tx
60
+ .insert(workflowManifestsTable)
61
+ .values({
62
+ environment: envelope.environment,
63
+ versionId: envelope.versionId,
64
+ manifest: envelope.manifest,
65
+ isCurrent: true,
66
+ })
67
+ .onConflictDoNothing({
68
+ target: [workflowManifestsTable.environment, workflowManifestsTable.versionId],
69
+ })
70
+
71
+ // Demote any existing current row that isn't this versionId.
72
+ await tx
73
+ .update(workflowManifestsTable)
74
+ .set({ isCurrent: false })
75
+ .where(
76
+ and(
77
+ eq(workflowManifestsTable.environment, envelope.environment),
78
+ eq(workflowManifestsTable.isCurrent, true),
79
+ ne(workflowManifestsTable.versionId, envelope.versionId),
80
+ ),
81
+ )
82
+
83
+ // Promote the just-registered row in case it already existed
84
+ // (re-register of the same versionId).
85
+ await tx
86
+ .update(workflowManifestsTable)
87
+ .set({ isCurrent: true })
88
+ .where(
89
+ and(
90
+ eq(workflowManifestsTable.environment, envelope.environment),
91
+ eq(workflowManifestsTable.versionId, envelope.versionId),
92
+ ),
93
+ )
94
+ })
95
+ return { versionId: envelope.versionId }
96
+ },
97
+
98
+ async getCurrent(environment) {
99
+ const rows = await db
100
+ .select()
101
+ .from(workflowManifestsTable)
102
+ .where(
103
+ and(
104
+ eq(workflowManifestsTable.environment, environment),
105
+ eq(workflowManifestsTable.isCurrent, true),
106
+ ),
107
+ )
108
+ .limit(1)
109
+ const row = rows[0]
110
+ if (!row) return null
111
+ return {
112
+ environment: row.environment,
113
+ versionId: row.versionId,
114
+ manifest: row.manifest,
115
+ }
116
+ },
117
+
118
+ async pruneToVersions(environment, keep) {
119
+ if (keep < 1) {
120
+ throw new Error(`pruneToVersions: keep must be >= 1, got ${keep}`)
121
+ }
122
+ // Get the IDs of the latest `keep` rows for this environment, then
123
+ // delete everything else.
124
+ const newest = await db
125
+ .select({ versionId: workflowManifestsTable.versionId })
126
+ .from(workflowManifestsTable)
127
+ .where(eq(workflowManifestsTable.environment, environment))
128
+ .orderBy(desc(workflowManifestsTable.registeredAt))
129
+ .limit(keep)
130
+ const keepIds = newest.map((r) => r.versionId)
131
+ if (keepIds.length === 0) return { deleted: 0 }
132
+
133
+ const result = await db.execute(
134
+ sql`DELETE FROM ${workflowManifestsTable}
135
+ WHERE environment = ${environment}
136
+ AND version_id NOT IN (${sql.join(
137
+ keepIds.map((id) => sql`${id}`),
138
+ sql`, `,
139
+ )})`,
140
+ )
141
+ return { deleted: (result as { rowCount?: number }).rowCount ?? 0 }
142
+ },
143
+ }
144
+ }