@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.
- package/dist/fs-run-record-store.d.ts.map +1 -1
- package/dist/fs-run-record-store.js +23 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/node-standalone-driver.d.ts +72 -0
- package/dist/node-standalone-driver.d.ts.map +1 -0
- package/dist/node-standalone-driver.js +357 -0
- package/dist/postgres-manifest-store.d.ts +35 -0
- package/dist/postgres-manifest-store.d.ts.map +1 -0
- package/dist/postgres-manifest-store.js +82 -0
- package/dist/postgres-run-record-store.d.ts +9 -0
- package/dist/postgres-run-record-store.d.ts.map +1 -0
- package/dist/postgres-run-record-store.js +161 -0
- package/dist/postgres-schema.d.ts +121 -0
- package/dist/postgres-schema.d.ts.map +1 -1
- package/dist/postgres-schema.js +40 -1
- package/drizzle/0003_idempotency_key.sql +2 -0
- package/drizzle/0004_workflow_manifests.sql +10 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +3 -3
- package/src/fs-run-record-store.ts +21 -0
- package/src/index.ts +15 -0
- package/src/node-standalone-driver.ts +491 -0
- package/src/postgres-manifest-store.ts +144 -0
- package/src/postgres-run-record-store.ts +187 -0
- package/src/postgres-schema.ts +56 -1
|
@@ -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 }
|
package/src/postgres-schema.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import {
|
|
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
|
+
)
|