@voyantjs/workflows-orchestrator-node 0.28.1 → 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,161 @@
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
+ import { and, desc, eq } from "drizzle-orm";
17
+ import { snapshotRunsTable } from "./postgres-schema.js";
18
+ export function createPostgresRunRecordStore(opts) {
19
+ return {
20
+ async get(id) {
21
+ const rows = await opts.db
22
+ .select()
23
+ .from(snapshotRunsTable)
24
+ .where(eq(snapshotRunsTable.id, id))
25
+ .limit(1);
26
+ const row = rows[0];
27
+ if (!row)
28
+ return undefined;
29
+ // The full state lives on `run_record`. Older rows persisted by
30
+ // `createPostgresSnapshotRunStore` may lack it; fall back to the
31
+ // denormalized columns so reads stay backwards-compatible.
32
+ const stored = asRunRecord(row.runRecord);
33
+ if (stored)
34
+ return stored;
35
+ return undefined;
36
+ },
37
+ async save(record) {
38
+ const values = recordToValues(record);
39
+ // Upsert by id — last-write-wins. Used for state mutations after
40
+ // the run is created (resume / cancel / drive). Idempotency on
41
+ // *creation* is enforced separately via `tryInsert` below; this
42
+ // path is the steady-state save.
43
+ await opts.db.insert(snapshotRunsTable).values(values).onConflictDoUpdate({
44
+ target: snapshotRunsTable.id,
45
+ set: values,
46
+ });
47
+ return record;
48
+ },
49
+ async tryInsert(record) {
50
+ const values = recordToValues(record);
51
+ // Atomic at the DB level: INSERT … ON CONFLICT DO NOTHING returns
52
+ // the row only if it was created, empty otherwise. When empty, we
53
+ // re-SELECT to load the existing record. This closes the race
54
+ // window between `get(id)` and `save(record)` that the previous
55
+ // get-then-upsert pattern left open — concurrent triggers with
56
+ // the same idempotency-derived runId now see deterministic
57
+ // "first writer wins" semantics.
58
+ const inserted = await opts.db
59
+ .insert(snapshotRunsTable)
60
+ .values(values)
61
+ .onConflictDoNothing({ target: snapshotRunsTable.id })
62
+ .returning({ id: snapshotRunsTable.id });
63
+ if (inserted.length > 0) {
64
+ return { record, created: true };
65
+ }
66
+ // Conflict — load whoever won the race.
67
+ const existingRows = await opts.db
68
+ .select()
69
+ .from(snapshotRunsTable)
70
+ .where(eq(snapshotRunsTable.id, record.id))
71
+ .limit(1);
72
+ const existingRow = existingRows[0];
73
+ if (!existingRow) {
74
+ // Pathological case: the conflict happened but we can't read it
75
+ // back. Surface as a write that became a no-op so the caller
76
+ // doesn't proceed to drive a non-existent run.
77
+ return { record, created: false };
78
+ }
79
+ const existing = asRunRecord(existingRow.runRecord);
80
+ return {
81
+ record: existing ?? record,
82
+ created: false,
83
+ };
84
+ },
85
+ async list(filter = {}) {
86
+ const conditions = [];
87
+ if (filter.workflowId) {
88
+ conditions.push(eq(snapshotRunsTable.workflowId, filter.workflowId));
89
+ }
90
+ if (filter.status) {
91
+ conditions.push(eq(snapshotRunsTable.status, filter.status));
92
+ }
93
+ let query = opts.db.select().from(snapshotRunsTable).$dynamic();
94
+ if (conditions.length === 1) {
95
+ query = query.where(conditions[0]);
96
+ }
97
+ else if (conditions.length > 1) {
98
+ query = query.where(and(...conditions));
99
+ }
100
+ query = query.orderBy(desc(snapshotRunsTable.startedAt));
101
+ if (filter.limit !== undefined) {
102
+ query = query.limit(filter.limit);
103
+ }
104
+ const rows = await query;
105
+ const out = [];
106
+ for (const row of rows) {
107
+ const stored = asRunRecord(row.runRecord);
108
+ if (stored)
109
+ out.push(stored);
110
+ }
111
+ return out;
112
+ },
113
+ };
114
+ }
115
+ // ---- Helpers (parallel to the snapshot store's; kept private here to
116
+ // avoid coupling between the two stores' representations) ----
117
+ function recordToValues(record) {
118
+ return {
119
+ id: record.id,
120
+ workflowId: record.workflowId,
121
+ status: record.status,
122
+ startedAt: record.startedAt,
123
+ completedAt: record.completedAt ?? null,
124
+ durationMs: record.completedAt !== undefined ? record.completedAt - record.startedAt : null,
125
+ tags: [...record.tags],
126
+ // `result` mirrors the snapshot-store convention: the run's public
127
+ // outcome view. We snapshot output + error here so the dashboard's
128
+ // reads remain consistent across both stores.
129
+ result: normalizeRequiredJson({
130
+ status: record.status,
131
+ output: record.output,
132
+ error: record.error,
133
+ startedAt: record.startedAt,
134
+ completedAt: record.completedAt,
135
+ }),
136
+ input: normalizeJson(record.input),
137
+ runRecord: normalizeRequiredJson(record),
138
+ entryFile: null,
139
+ replayOf: null,
140
+ idempotencyKey: record.idempotencyKey ?? null,
141
+ };
142
+ }
143
+ function asRunRecord(value) {
144
+ if (typeof value !== "object" || value === null)
145
+ return undefined;
146
+ // Sanity check: every RunRecord has at least { id, status, journal }.
147
+ const v = value;
148
+ if (typeof v.id !== "string")
149
+ return undefined;
150
+ if (typeof v.status !== "string")
151
+ return undefined;
152
+ return value;
153
+ }
154
+ function normalizeJson(value) {
155
+ if (value === undefined)
156
+ return null;
157
+ return JSON.parse(JSON.stringify(value, (_key, nested) => (typeof nested === "bigint" ? Number(nested) : nested)));
158
+ }
159
+ function normalizeRequiredJson(value) {
160
+ return JSON.parse(JSON.stringify(value, (_key, nested) => (typeof nested === "bigint" ? Number(nested) : nested)));
161
+ }
@@ -214,6 +214,23 @@ export declare const snapshotRunsTable: import("drizzle-orm/pg-core").PgTableWit
214
214
  identity: undefined;
215
215
  generated: undefined;
216
216
  }, {}, {}>;
217
+ idempotencyKey: import("drizzle-orm/pg-core").PgColumn<{
218
+ name: "idempotency_key";
219
+ tableName: "voyant_snapshot_runs";
220
+ dataType: "string";
221
+ columnType: "PgText";
222
+ data: string;
223
+ driverParam: string;
224
+ notNull: false;
225
+ hasDefault: false;
226
+ isPrimaryKey: false;
227
+ isAutoincrement: false;
228
+ hasRuntimeDefault: false;
229
+ enumValues: [string, ...string[]];
230
+ baseColumn: never;
231
+ identity: undefined;
232
+ generated: undefined;
233
+ }, {}, {}>;
217
234
  };
218
235
  dialect: "pg";
219
236
  }>;
@@ -309,4 +326,108 @@ export declare const wakeupsTable: import("drizzle-orm/pg-core").PgTableWithColu
309
326
  };
310
327
  dialect: "pg";
311
328
  }>;
329
+ /**
330
+ * Manifest store. Holds workflow + event-filter manifests pushed at
331
+ * `createApp()` boot via `driver.registerManifest(...)`. Last N versions
332
+ * retained per environment; `is_current` points to the active version.
333
+ *
334
+ * One row is "current" per environment, enforced by the partial unique
335
+ * index `voyant_workflow_manifests_current_idx`.
336
+ *
337
+ * See architecture doc §14 for the manifest lifecycle.
338
+ */
339
+ export declare const workflowManifestsTable: import("drizzle-orm/pg-core").PgTableWithColumns<{
340
+ name: "voyant_workflow_manifests";
341
+ schema: undefined;
342
+ columns: {
343
+ environment: import("drizzle-orm/pg-core").PgColumn<{
344
+ name: "environment";
345
+ tableName: "voyant_workflow_manifests";
346
+ dataType: "string";
347
+ columnType: "PgText";
348
+ data: string;
349
+ driverParam: string;
350
+ notNull: true;
351
+ hasDefault: false;
352
+ isPrimaryKey: false;
353
+ isAutoincrement: false;
354
+ hasRuntimeDefault: false;
355
+ enumValues: [string, ...string[]];
356
+ baseColumn: never;
357
+ identity: undefined;
358
+ generated: undefined;
359
+ }, {}, {}>;
360
+ versionId: import("drizzle-orm/pg-core").PgColumn<{
361
+ name: "version_id";
362
+ tableName: "voyant_workflow_manifests";
363
+ dataType: "string";
364
+ columnType: "PgText";
365
+ data: string;
366
+ driverParam: string;
367
+ notNull: true;
368
+ hasDefault: false;
369
+ isPrimaryKey: false;
370
+ isAutoincrement: false;
371
+ hasRuntimeDefault: false;
372
+ enumValues: [string, ...string[]];
373
+ baseColumn: never;
374
+ identity: undefined;
375
+ generated: undefined;
376
+ }, {}, {}>;
377
+ manifest: import("drizzle-orm/pg-core").PgColumn<{
378
+ name: "manifest";
379
+ tableName: "voyant_workflow_manifests";
380
+ dataType: "json";
381
+ columnType: "PgJsonb";
382
+ data: Record<string, unknown>;
383
+ driverParam: unknown;
384
+ notNull: true;
385
+ hasDefault: false;
386
+ isPrimaryKey: false;
387
+ isAutoincrement: false;
388
+ hasRuntimeDefault: false;
389
+ enumValues: undefined;
390
+ baseColumn: never;
391
+ identity: undefined;
392
+ generated: undefined;
393
+ }, {}, {
394
+ $type: Record<string, unknown>;
395
+ }>;
396
+ registeredAt: import("drizzle-orm/pg-core").PgColumn<{
397
+ name: "registered_at";
398
+ tableName: "voyant_workflow_manifests";
399
+ dataType: "date";
400
+ columnType: "PgTimestamp";
401
+ data: Date;
402
+ driverParam: string;
403
+ notNull: true;
404
+ hasDefault: true;
405
+ isPrimaryKey: false;
406
+ isAutoincrement: false;
407
+ hasRuntimeDefault: false;
408
+ enumValues: undefined;
409
+ baseColumn: never;
410
+ identity: undefined;
411
+ generated: undefined;
412
+ }, {}, {}>;
413
+ isCurrent: import("drizzle-orm/pg-core").PgColumn<{
414
+ name: "is_current";
415
+ tableName: "voyant_workflow_manifests";
416
+ dataType: "boolean";
417
+ columnType: "PgBoolean";
418
+ data: boolean;
419
+ driverParam: boolean;
420
+ notNull: true;
421
+ hasDefault: true;
422
+ isPrimaryKey: false;
423
+ isAutoincrement: false;
424
+ hasRuntimeDefault: false;
425
+ enumValues: undefined;
426
+ baseColumn: never;
427
+ identity: undefined;
428
+ generated: undefined;
429
+ }, {}, {}>;
430
+ };
431
+ dialect: "pg";
432
+ }>;
312
433
  //# sourceMappingURL=postgres-schema.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"postgres-schema.d.ts","sourceRoot":"","sources":["../src/postgres-schema.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0B7B,CAAA;AAED,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaxB,CAAA"}
1
+ {"version":3,"file":"postgres-schema.d.ts","sourceRoot":"","sources":["../src/postgres-schema.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0C7B,CAAA;AAED,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAaxB,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAelC,CAAA"}
@@ -1,4 +1,5 @@
1
- import { bigint, index, integer, jsonb, pgTable, text } from "drizzle-orm/pg-core";
1
+ import { sql } from "drizzle-orm";
2
+ import { bigint, boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, } from "drizzle-orm/pg-core";
2
3
  export const snapshotRunsTable = pgTable("voyant_snapshot_runs", {
3
4
  id: text("id").primaryKey(),
4
5
  workflowId: text("workflow_id").notNull(),
@@ -12,9 +13,25 @@ export const snapshotRunsTable = pgTable("voyant_snapshot_runs", {
12
13
  runRecord: jsonb("run_record").$type(),
13
14
  entryFile: text("entry_file"),
14
15
  replayOf: text("replay_of"),
16
+ /**
17
+ * Caller-supplied idempotency token, mirrored from
18
+ * `RunRecord.idempotencyKey` / `TriggerArgs.idempotencyKey`.
19
+ * The unique partial index below enforces dedup on
20
+ * `(workflow_id, idempotency_key)`; null values don't participate.
21
+ */
22
+ idempotencyKey: text("idempotency_key"),
15
23
  }, (table) => ({
16
24
  workflowStartedIdx: index("voyant_snapshot_runs_workflow_started_idx").on(table.workflowId, table.startedAt),
17
25
  statusStartedIdx: index("voyant_snapshot_runs_status_started_idx").on(table.status, table.startedAt),
26
+ /**
27
+ * Unique partial index — enforces idempotency dedup on
28
+ * `(workflow_id, idempotency_key)` while letting null keys coexist.
29
+ * Read in `createPostgresSnapshotRunStore` via `INSERT … ON CONFLICT
30
+ * DO NOTHING RETURNING id`.
31
+ */
32
+ idempotencyIdx: uniqueIndex("voyant_snapshot_runs_idempotency_idx")
33
+ .on(table.workflowId, table.idempotencyKey)
34
+ .where(sql `${table.idempotencyKey} IS NOT NULL`),
18
35
  }));
19
36
  export const wakeupsTable = pgTable("voyant_wakeups", {
20
37
  runId: text("run_id").primaryKey(),
@@ -26,3 +43,25 @@ export const wakeupsTable = pgTable("voyant_wakeups", {
26
43
  dueIdx: index("voyant_wakeups_due_idx").on(table.wakeAt),
27
44
  leaseIdx: index("voyant_wakeups_lease_idx").on(table.leaseExpiresAt),
28
45
  }));
46
+ /**
47
+ * Manifest store. Holds workflow + event-filter manifests pushed at
48
+ * `createApp()` boot via `driver.registerManifest(...)`. Last N versions
49
+ * retained per environment; `is_current` points to the active version.
50
+ *
51
+ * One row is "current" per environment, enforced by the partial unique
52
+ * index `voyant_workflow_manifests_current_idx`.
53
+ *
54
+ * See architecture doc §14 for the manifest lifecycle.
55
+ */
56
+ export const workflowManifestsTable = pgTable("voyant_workflow_manifests", {
57
+ environment: text("environment").notNull(),
58
+ versionId: text("version_id").notNull(),
59
+ manifest: jsonb("manifest").$type().notNull(),
60
+ registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(),
61
+ isCurrent: boolean("is_current").notNull().default(false),
62
+ }, (table) => ({
63
+ pk: primaryKey({ columns: [table.environment, table.versionId] }),
64
+ currentIdx: uniqueIndex("voyant_workflow_manifests_current_idx")
65
+ .on(table.environment)
66
+ .where(sql `${table.isCurrent}`),
67
+ }));
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "voyant_snapshot_runs" ADD COLUMN "idempotency_key" text;--> statement-breakpoint
2
+ CREATE UNIQUE INDEX "voyant_snapshot_runs_idempotency_idx" ON "voyant_snapshot_runs" USING btree ("workflow_id","idempotency_key") WHERE "voyant_snapshot_runs"."idempotency_key" IS NOT NULL;
@@ -0,0 +1,10 @@
1
+ CREATE TABLE "voyant_workflow_manifests" (
2
+ "environment" text NOT NULL,
3
+ "version_id" text NOT NULL,
4
+ "manifest" jsonb NOT NULL,
5
+ "registered_at" timestamp with time zone DEFAULT now() NOT NULL,
6
+ "is_current" boolean DEFAULT false NOT NULL,
7
+ CONSTRAINT "voyant_workflow_manifests_environment_version_id_pk" PRIMARY KEY("environment","version_id")
8
+ );
9
+ --> statement-breakpoint
10
+ CREATE UNIQUE INDEX "voyant_workflow_manifests_current_idx" ON "voyant_workflow_manifests" USING btree ("environment") WHERE "voyant_workflow_manifests"."is_current";
@@ -22,6 +22,20 @@
22
22
  "when": 1776506061874,
23
23
  "tag": "0002_allow_null_input",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1779000000000,
30
+ "tag": "0003_idempotency_key",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1779000001000,
37
+ "tag": "0004_workflow_manifests",
38
+ "breakpoints": true
25
39
  }
26
40
  ]
27
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyantjs/workflows-orchestrator-node",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "Node/Docker runtime primitives for @voyantjs/workflows-orchestrator, including a file-backed run store and local scheduler.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "drizzle-orm": "^0.45.2",
31
31
  "pg": "^8.20.0",
32
- "@voyantjs/workflows-orchestrator": "0.28.1",
33
- "@voyantjs/workflows": "0.28.1"
32
+ "@voyantjs/workflows-orchestrator": "0.29.0",
33
+ "@voyantjs/workflows": "0.29.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^20.12.0",
@@ -25,6 +25,27 @@ export function createFsRunRecordStore(opts: FsRunRecordStoreOptions = {}): RunR
25
25
  return record
26
26
  },
27
27
 
28
+ async tryInsert(record) {
29
+ // Single-process FS store: atomicity comes from `mkdir` without
30
+ // `recursive: true` — succeeds exactly once per path, EEXIST
31
+ // otherwise. The orchestrator only enters this branch for
32
+ // idempotency-derived runIds, so the directory's prior existence
33
+ // means another caller already created the run.
34
+ try {
35
+ await mkdir(rootDir, { recursive: true })
36
+ await mkdir(join(rootDir, record.id))
37
+ } catch (err) {
38
+ const code = (err as NodeJS.ErrnoException).code
39
+ if (code !== "EEXIST") throw err
40
+ const existing = await readRunFile(join(rootDir, record.id, "run-record.json"))
41
+ if (existing) return { record: existing, created: false }
42
+ // Dir exists but no record file — partial-write race window;
43
+ // fall through and write our record.
44
+ }
45
+ await writeFile(join(rootDir, record.id, "run-record.json"), JSON.stringify(record, null, 2))
46
+ return { record, created: true }
47
+ },
48
+
28
49
  async list(filter = {}) {
29
50
  const entries = await safeReaddir(rootDir)
30
51
  const runs: RunRecord[] = []
package/src/index.ts CHANGED
@@ -39,6 +39,10 @@ export {
39
39
  type RunPostgresMigrationsOptions,
40
40
  runPostgresMigrations,
41
41
  } from "./migrate.js"
42
+ export {
43
+ createNodeStandaloneDriver,
44
+ type NodeStandaloneDriverOptions,
45
+ } from "./node-standalone-driver.js"
42
46
  export {
43
47
  createPersistentWakeupManager,
44
48
  type PersistentWakeupManager,
@@ -49,9 +53,20 @@ export {
49
53
  createPostgresConnection,
50
54
  type PostgresConnection,
51
55
  } from "./postgres.js"
56
+ export {
57
+ createPostgresManifestStore,
58
+ type ManifestEnvelope,
59
+ type ManifestStore,
60
+ type PostgresManifestStoreOptions,
61
+ } from "./postgres-manifest-store.js"
62
+ export {
63
+ createPostgresRunRecordStore,
64
+ type PostgresRunRecordStoreOptions,
65
+ } from "./postgres-run-record-store.js"
52
66
  export {
53
67
  snapshotRunsTable,
54
68
  wakeupsTable,
69
+ workflowManifestsTable,
55
70
  } from "./postgres-schema.js"
56
71
  export {
57
72
  createPostgresSnapshotRunStore,