@voyantjs/workflows-orchestrator-node 0.28.3 → 0.29.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,187 @@
1
+ // Postgres-backed RunRecordStore — implements the orchestrator's primary
2
+ // state-store interface (`@voyantjs/workflows-orchestrator/RunRecordStore`)
3
+ // against the existing `voyant_snapshot_runs` table.
4
+ //
5
+ // The snapshot table already carries a `run_record` JSONB column; this
6
+ // store uses that for the full RunRecord plus the indexed columns
7
+ // (`workflow_id`, `status`, `started_at`, etc.) for queries. Mode 2's
8
+ // `createNodeStandaloneDriver` plugs this into the orchestrator core's
9
+ // pure trigger/resume/cancel functions.
10
+ //
11
+ // The new `idempotency_key` column populated from `RunRecord.idempotencyKey`
12
+ // is enforced by the unique partial index added in migration 0003 — the
13
+ // orchestrator's deterministic-runId derivation from `idempotencyKey`
14
+ // dedups via the row primary key as well, so this index is a defensive
15
+ // safety net.
16
+
17
+ import type {
18
+ OrchestratorRunStatus,
19
+ RunRecord,
20
+ RunRecordStore,
21
+ } from "@voyantjs/workflows-orchestrator"
22
+ import { and, desc, eq } from "drizzle-orm"
23
+ import type { drizzle } from "drizzle-orm/node-postgres"
24
+
25
+ import { snapshotRunsTable } from "./postgres-schema.js"
26
+
27
+ type SnapshotDb = ReturnType<typeof drizzle>
28
+
29
+ export interface PostgresRunRecordStoreOptions {
30
+ db: SnapshotDb
31
+ }
32
+
33
+ export function createPostgresRunRecordStore(opts: PostgresRunRecordStoreOptions): RunRecordStore {
34
+ return {
35
+ async get(id) {
36
+ const rows = await opts.db
37
+ .select()
38
+ .from(snapshotRunsTable)
39
+ .where(eq(snapshotRunsTable.id, id))
40
+ .limit(1)
41
+ const row = rows[0]
42
+ if (!row) return undefined
43
+ // The full state lives on `run_record`. Older rows persisted by
44
+ // `createPostgresSnapshotRunStore` may lack it; fall back to the
45
+ // denormalized columns so reads stay backwards-compatible.
46
+ const stored = asRunRecord(row.runRecord)
47
+ if (stored) return stored
48
+ return undefined
49
+ },
50
+
51
+ async save(record) {
52
+ const values = recordToValues(record)
53
+ // Upsert by id — last-write-wins. Used for state mutations after
54
+ // the run is created (resume / cancel / drive). Idempotency on
55
+ // *creation* is enforced separately via `tryInsert` below; this
56
+ // path is the steady-state save.
57
+ await opts.db.insert(snapshotRunsTable).values(values).onConflictDoUpdate({
58
+ target: snapshotRunsTable.id,
59
+ set: values,
60
+ })
61
+ return record
62
+ },
63
+
64
+ async tryInsert(record) {
65
+ const values = recordToValues(record)
66
+ // Atomic at the DB level: INSERT … ON CONFLICT DO NOTHING returns
67
+ // the row only if it was created, empty otherwise. When empty, we
68
+ // re-SELECT to load the existing record. This closes the race
69
+ // window between `get(id)` and `save(record)` that the previous
70
+ // get-then-upsert pattern left open — concurrent triggers with
71
+ // the same idempotency-derived runId now see deterministic
72
+ // "first writer wins" semantics.
73
+ const inserted = await opts.db
74
+ .insert(snapshotRunsTable)
75
+ .values(values)
76
+ .onConflictDoNothing({ target: snapshotRunsTable.id })
77
+ .returning({ id: snapshotRunsTable.id })
78
+
79
+ if (inserted.length > 0) {
80
+ return { record, created: true }
81
+ }
82
+ // Conflict — load whoever won the race.
83
+ const existingRows = await opts.db
84
+ .select()
85
+ .from(snapshotRunsTable)
86
+ .where(eq(snapshotRunsTable.id, record.id))
87
+ .limit(1)
88
+ const existingRow = existingRows[0]
89
+ if (!existingRow) {
90
+ // Pathological case: the conflict happened but we can't read it
91
+ // back. Surface as a write that became a no-op so the caller
92
+ // doesn't proceed to drive a non-existent run.
93
+ return { record, created: false }
94
+ }
95
+ const existing = asRunRecord(existingRow.runRecord)
96
+ return {
97
+ record: existing ?? record,
98
+ created: false,
99
+ }
100
+ },
101
+
102
+ async list(filter = {}) {
103
+ const conditions = []
104
+ if (filter.workflowId) {
105
+ conditions.push(eq(snapshotRunsTable.workflowId, filter.workflowId))
106
+ }
107
+ if (filter.status) {
108
+ conditions.push(eq(snapshotRunsTable.status, filter.status))
109
+ }
110
+
111
+ let query = opts.db.select().from(snapshotRunsTable).$dynamic()
112
+ if (conditions.length === 1) {
113
+ query = query.where(conditions[0]!)
114
+ } else if (conditions.length > 1) {
115
+ query = query.where(and(...conditions))
116
+ }
117
+ query = query.orderBy(desc(snapshotRunsTable.startedAt))
118
+ if (filter.limit !== undefined) {
119
+ query = query.limit(filter.limit)
120
+ }
121
+ const rows = await query
122
+
123
+ const out: RunRecord[] = []
124
+ for (const row of rows) {
125
+ const stored = asRunRecord(row.runRecord)
126
+ if (stored) out.push(stored)
127
+ }
128
+ return out
129
+ },
130
+ }
131
+ }
132
+
133
+ // ---- Helpers (parallel to the snapshot store's; kept private here to
134
+ // avoid coupling between the two stores' representations) ----
135
+
136
+ function recordToValues(record: RunRecord) {
137
+ return {
138
+ id: record.id,
139
+ workflowId: record.workflowId,
140
+ status: record.status,
141
+ startedAt: record.startedAt,
142
+ completedAt: record.completedAt ?? null,
143
+ durationMs: record.completedAt !== undefined ? record.completedAt - record.startedAt : null,
144
+ tags: [...record.tags],
145
+ // `result` mirrors the snapshot-store convention: the run's public
146
+ // outcome view. We snapshot output + error here so the dashboard's
147
+ // reads remain consistent across both stores.
148
+ result: normalizeRequiredJson({
149
+ status: record.status,
150
+ output: record.output,
151
+ error: record.error,
152
+ startedAt: record.startedAt,
153
+ completedAt: record.completedAt,
154
+ }),
155
+ input: normalizeJson(record.input),
156
+ runRecord: normalizeRequiredJson(record as unknown as Record<string, unknown>),
157
+ entryFile: null,
158
+ replayOf: null,
159
+ idempotencyKey: record.idempotencyKey ?? null,
160
+ }
161
+ }
162
+
163
+ function asRunRecord(value: unknown): RunRecord | undefined {
164
+ if (typeof value !== "object" || value === null) return undefined
165
+ // Sanity check: every RunRecord has at least { id, status, journal }.
166
+ const v = value as Record<string, unknown>
167
+ if (typeof v.id !== "string") return undefined
168
+ if (typeof v.status !== "string") return undefined
169
+ return value as RunRecord
170
+ }
171
+
172
+ function normalizeJson<T>(value: T): T | null {
173
+ if (value === undefined) return null
174
+ return JSON.parse(
175
+ JSON.stringify(value, (_key, nested) => (typeof nested === "bigint" ? Number(nested) : nested)),
176
+ ) as T
177
+ }
178
+
179
+ function normalizeRequiredJson<T>(value: T): T {
180
+ return JSON.parse(
181
+ JSON.stringify(value, (_key, nested) => (typeof nested === "bigint" ? Number(nested) : nested)),
182
+ ) as T
183
+ }
184
+
185
+ // Re-export for type discoverability — the orchestrator's status union
186
+ // shows up frequently in consumer code that filters runs by status.
187
+ export type { OrchestratorRunStatus }
@@ -1,4 +1,16 @@
1
- import { bigint, index, integer, jsonb, pgTable, text } from "drizzle-orm/pg-core"
1
+ import { sql } from "drizzle-orm"
2
+ import {
3
+ bigint,
4
+ boolean,
5
+ index,
6
+ integer,
7
+ jsonb,
8
+ pgTable,
9
+ primaryKey,
10
+ text,
11
+ timestamp,
12
+ uniqueIndex,
13
+ } from "drizzle-orm/pg-core"
2
14
 
3
15
  export const snapshotRunsTable = pgTable(
4
16
  "voyant_snapshot_runs",
@@ -15,6 +27,13 @@ export const snapshotRunsTable = pgTable(
15
27
  runRecord: jsonb("run_record").$type<Record<string, unknown>>(),
16
28
  entryFile: text("entry_file"),
17
29
  replayOf: text("replay_of"),
30
+ /**
31
+ * Caller-supplied idempotency token, mirrored from
32
+ * `RunRecord.idempotencyKey` / `TriggerArgs.idempotencyKey`.
33
+ * The unique partial index below enforces dedup on
34
+ * `(workflow_id, idempotency_key)`; null values don't participate.
35
+ */
36
+ idempotencyKey: text("idempotency_key"),
18
37
  },
19
38
  (table) => ({
20
39
  workflowStartedIdx: index("voyant_snapshot_runs_workflow_started_idx").on(
@@ -25,6 +44,15 @@ export const snapshotRunsTable = pgTable(
25
44
  table.status,
26
45
  table.startedAt,
27
46
  ),
47
+ /**
48
+ * Unique partial index — enforces idempotency dedup on
49
+ * `(workflow_id, idempotency_key)` while letting null keys coexist.
50
+ * Read in `createPostgresSnapshotRunStore` via `INSERT … ON CONFLICT
51
+ * DO NOTHING RETURNING id`.
52
+ */
53
+ idempotencyIdx: uniqueIndex("voyant_snapshot_runs_idempotency_idx")
54
+ .on(table.workflowId, table.idempotencyKey)
55
+ .where(sql`${table.idempotencyKey} IS NOT NULL`),
28
56
  }),
29
57
  )
30
58
 
@@ -42,3 +70,30 @@ export const wakeupsTable = pgTable(
42
70
  leaseIdx: index("voyant_wakeups_lease_idx").on(table.leaseExpiresAt),
43
71
  }),
44
72
  )
73
+
74
+ /**
75
+ * Manifest store. Holds workflow + event-filter manifests pushed at
76
+ * `createApp()` boot via `driver.registerManifest(...)`. Last N versions
77
+ * retained per environment; `is_current` points to the active version.
78
+ *
79
+ * One row is "current" per environment, enforced by the partial unique
80
+ * index `voyant_workflow_manifests_current_idx`.
81
+ *
82
+ * See architecture doc §14 for the manifest lifecycle.
83
+ */
84
+ export const workflowManifestsTable = pgTable(
85
+ "voyant_workflow_manifests",
86
+ {
87
+ environment: text("environment").notNull(),
88
+ versionId: text("version_id").notNull(),
89
+ manifest: jsonb("manifest").$type<Record<string, unknown>>().notNull(),
90
+ registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(),
91
+ isCurrent: boolean("is_current").notNull().default(false),
92
+ },
93
+ (table) => ({
94
+ pk: primaryKey({ columns: [table.environment, table.versionId] }),
95
+ currentIdx: uniqueIndex("voyant_workflow_manifests_current_idx")
96
+ .on(table.environment)
97
+ .where(sql`${table.isCurrent}`),
98
+ }),
99
+ )