@axiom-lattice/pg-stores 1.0.73 → 1.0.75

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/jest.config.js ADDED
@@ -0,0 +1,19 @@
1
+ module.exports = {
2
+ preset: "ts-jest",
3
+ testEnvironment: "node",
4
+ roots: ["<rootDir>/src"],
5
+ testMatch: ["**/__tests__/**/*.test.ts"],
6
+ transform: {
7
+ "^.+\\.ts$": [
8
+ "ts-jest",
9
+ {
10
+ isolatedModules: true,
11
+ },
12
+ ],
13
+ },
14
+ moduleNameMapper: {
15
+ "^@axiom-lattice/protocols$": "<rootDir>/../protocols/src/index.ts",
16
+ },
17
+ transformIgnorePatterns: [],
18
+ testTimeout: 10000,
19
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "1.0.73",
3
+ "version": "1.0.75",
4
4
  "description": "PG stores implementation for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -23,7 +23,7 @@
23
23
  "@langchain/langgraph-checkpoint-postgres": "1.0.0",
24
24
  "pg": "^8.16.3",
25
25
  "uuid": "^9.0.1",
26
- "@axiom-lattice/core": "2.1.82",
26
+ "@axiom-lattice/core": "2.1.84",
27
27
  "@axiom-lattice/protocols": "2.1.43"
28
28
  },
29
29
  "devDependencies": {
@@ -38,6 +38,7 @@
38
38
  "build": "tsup src/index.ts --format cjs,esm --dts --sourcemap",
39
39
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
40
40
  "lint": "tsc --noEmit && node scripts/check-migration-versions.mjs",
41
+ "test": "jest",
41
42
  "clean": "rimraf dist"
42
43
  }
43
44
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Unit tests for row mapping and safeParse utilities.
3
+ *
4
+ * These are pure-function tests — no database required.
5
+ * They verify that corrupted JSON in stored columns won't crash the API.
6
+ */
7
+ import { safeParse, mapRowToWorkflowRun, mapRowToRunStep } from "../stores/PostgreSQLWorkflowTrackingStore";
8
+
9
+ // ─── safeParse ──────────────────────────────────────────────────────────────
10
+
11
+ describe("safeParse", () => {
12
+ test("parses valid JSON string", () => {
13
+ expect(safeParse('{"a":1}', {})).toEqual({ a: 1 });
14
+ });
15
+
16
+ test("parses JSON array", () => {
17
+ expect(safeParse("[1,2,3]", [])).toEqual([1, 2, 3]);
18
+ });
19
+
20
+ test("returns fallback for invalid JSON", () => {
21
+ expect(safeParse("not json", {})).toEqual({});
22
+ });
23
+
24
+ test("returns fallback for empty string", () => {
25
+ expect(safeParse("", [])).toEqual([]);
26
+ });
27
+
28
+ test("returns fallback for malformed JSON (unclosed brace)", () => {
29
+ expect(safeParse('{"a":1', {})).toEqual({});
30
+ });
31
+
32
+ test("returns fallback for plain text", () => {
33
+ expect(safeParse("hello world", "fallback")).toBe("fallback");
34
+ });
35
+
36
+ test("passes through non-string value (object)", () => {
37
+ const obj = { x: 1 };
38
+ expect(safeParse(obj, {})).toBe(obj);
39
+ });
40
+
41
+ test("returns fallback for null", () => {
42
+ expect(safeParse(null, "fb")).toBe("fb");
43
+ });
44
+
45
+ test("returns fallback for undefined", () => {
46
+ expect(safeParse(undefined, "fb")).toBe("fb");
47
+ });
48
+
49
+ test("passes through number", () => {
50
+ expect(safeParse(42, 0)).toBe(42);
51
+ });
52
+
53
+ test("parses nested JSON safely", () => {
54
+ const json = '{"a":{"b":{"c":[1,2,3]}},"d":"text"}';
55
+ expect(safeParse(json, {})).toEqual({ a: { b: { c: [1, 2, 3] } }, d: "text" });
56
+ });
57
+
58
+ test("handles JSON with unicode", () => {
59
+ expect(safeParse('{"msg":"你好世界"}', {})).toEqual({ msg: "你好世界" });
60
+ });
61
+ });
62
+
63
+ // ─── mapRowToWorkflowRun ────────────────────────────────────────────────────
64
+
65
+ describe("mapRowToWorkflowRun", () => {
66
+ const baseRow = {
67
+ id: "run-1",
68
+ tenant_id: "t1",
69
+ assistant_id: "a1",
70
+ thread_id: "th1",
71
+ status: "running",
72
+ topology_edges: null, // JSONB returns parsed object, not string
73
+ total_edges: 3,
74
+ completed_edges: 1,
75
+ error_message: null,
76
+ metadata: null,
77
+ started_at: new Date("2026-01-01"),
78
+ completed_at: null,
79
+ created_at: new Date("2026-01-01"),
80
+ updated_at: new Date("2026-01-01"),
81
+ };
82
+
83
+ test("maps a clean row", () => {
84
+ const result = mapRowToWorkflowRun({
85
+ ...baseRow,
86
+ topology_edges: [{ from: "A", to: "B", purpose: "test" }],
87
+ metadata: { key: "value" },
88
+ });
89
+ expect(result.id).toBe("run-1");
90
+ expect(result.topologyEdges).toEqual([{ from: "A", to: "B", purpose: "test" }]);
91
+ expect(result.metadata).toEqual({ key: "value" });
92
+ expect(result.status).toBe("running");
93
+ expect(result.totalEdges).toBe(3);
94
+ });
95
+
96
+ test("falls back to empty array for null topology_edges", () => {
97
+ const result = mapRowToWorkflowRun({ ...baseRow, topology_edges: null });
98
+ expect(result.topologyEdges).toEqual([]);
99
+ });
100
+
101
+ test("falls back to empty object for null metadata", () => {
102
+ const result = mapRowToWorkflowRun({ ...baseRow, metadata: null });
103
+ expect(result.metadata).toEqual({});
104
+ });
105
+
106
+ test("handles topology_edges as JSON string (legacy TEXT column)", () => {
107
+ const result = mapRowToWorkflowRun({
108
+ ...baseRow,
109
+ topology_edges: JSON.stringify([{ from: "X", to: "Y", purpose: "legacy" }]),
110
+ });
111
+ expect(result.topologyEdges).toEqual([{ from: "X", to: "Y", purpose: "legacy" }]);
112
+ });
113
+
114
+ test("handles metadata as JSON string (legacy TEXT column)", () => {
115
+ const result = mapRowToWorkflowRun({
116
+ ...baseRow,
117
+ metadata: JSON.stringify({ legacy: true }),
118
+ });
119
+ expect(result.metadata).toEqual({ legacy: true });
120
+ });
121
+
122
+ test("safe-falls back on corrupted topology_edges string — does NOT throw", () => {
123
+ expect(() =>
124
+ mapRowToWorkflowRun({ ...baseRow, topology_edges: "{{{bad}}" })
125
+ ).not.toThrow();
126
+ });
127
+
128
+ test("safe-falls back on corrupted metadata string — does NOT throw", () => {
129
+ expect(() =>
130
+ mapRowToWorkflowRun({ ...baseRow, metadata: "not-json-at-all" })
131
+ ).not.toThrow();
132
+ });
133
+
134
+ test("returns fallback values for corrupted strings", () => {
135
+ const result = mapRowToWorkflowRun({
136
+ ...baseRow,
137
+ topology_edges: "bad",
138
+ metadata: "also bad",
139
+ });
140
+ expect(result.topologyEdges).toEqual([]);
141
+ expect(result.metadata).toEqual({});
142
+ });
143
+ });
144
+
145
+ // ─── mapRowToRunStep ────────────────────────────────────────────────────────
146
+
147
+ describe("mapRowToRunStep", () => {
148
+ const baseRow = {
149
+ id: "step-1",
150
+ run_id: "run-1",
151
+ tenant_id: "t1",
152
+ step_type: "agent",
153
+ step_name: "MyStep",
154
+ thread_id: null,
155
+ edge_from: null,
156
+ edge_to: null,
157
+ edge_purpose: null,
158
+ input: null,
159
+ output: null,
160
+ status: "running",
161
+ error_message: null,
162
+ started_at: new Date("2026-01-01"),
163
+ completed_at: null,
164
+ duration_ms: null,
165
+ created_at: new Date("2026-01-01"),
166
+ updated_at: new Date("2026-01-01"),
167
+ };
168
+
169
+ test("maps a clean row", () => {
170
+ const result = mapRowToRunStep({
171
+ ...baseRow,
172
+ input: { template: "hello", rendered: "world" },
173
+ output: { result: "done" },
174
+ thread_id: "sub-thread-1",
175
+ });
176
+ expect(result.id).toBe("step-1");
177
+ expect(result.input).toEqual({ template: "hello", rendered: "world" });
178
+ expect(result.output).toEqual({ result: "done" });
179
+ expect(result.threadId).toBe("sub-thread-1");
180
+ });
181
+
182
+ test("handles null input and output (maps to undefined)", () => {
183
+ const result = mapRowToRunStep(baseRow);
184
+ expect(result.input).toBeUndefined();
185
+ expect(result.output).toBeUndefined();
186
+ });
187
+
188
+ test("handles input as JSON string (legacy TEXT column)", () => {
189
+ const result = mapRowToRunStep({
190
+ ...baseRow,
191
+ input: JSON.stringify({ template: "t", rendered: "r" }),
192
+ });
193
+ expect(result.input).toEqual({ template: "t", rendered: "r" });
194
+ });
195
+
196
+ test("handles output as JSON string (legacy TEXT column)", () => {
197
+ const result = mapRowToRunStep({
198
+ ...baseRow,
199
+ output: JSON.stringify({ result: "ok" }),
200
+ });
201
+ expect(result.output).toEqual({ result: "ok" });
202
+ });
203
+
204
+ test("safe-falls back on corrupted input string — does NOT throw", () => {
205
+ expect(() =>
206
+ mapRowToRunStep({ ...baseRow, input: "{{{corrupt" })
207
+ ).not.toThrow();
208
+ });
209
+
210
+ test("safe-falls back on corrupted output string — does NOT throw", () => {
211
+ expect(() =>
212
+ mapRowToRunStep({ ...baseRow, output: "bad-json-here" })
213
+ ).not.toThrow();
214
+ });
215
+
216
+ test("returns undefined for corrupted input/output strings", () => {
217
+ const result = mapRowToRunStep({
218
+ ...baseRow,
219
+ input: "invalid",
220
+ output: "also invalid",
221
+ });
222
+ expect(result.input).toBeUndefined();
223
+ expect(result.output).toBeUndefined();
224
+ });
225
+
226
+ test("handles deeply nested objects", () => {
227
+ const result = mapRowToRunStep({
228
+ ...baseRow,
229
+ output: { deep: { nested: { arr: [1, { x: "y" }] } } },
230
+ });
231
+ expect(result.output).toEqual({ deep: { nested: { arr: [1, { x: "y" }] } } });
232
+ });
233
+ });
@@ -98,7 +98,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
98
98
  `SELECT * FROM lattice_workflow_runs WHERE id = $1`, [runId]
99
99
  );
100
100
  if (result.rows.length === 0) return null;
101
- return this.mapRowToWorkflowRun(result.rows[0]);
101
+ return mapRowToWorkflowRun(result.rows[0]);
102
102
  }
103
103
 
104
104
  async updateWorkflowRun(runId: string, updates: UpdateWorkflowRunRequest): Promise<WorkflowRun | null> {
@@ -145,7 +145,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
145
145
  `SELECT * FROM lattice_workflow_runs WHERE tenant_id = $1 AND thread_id = $2 ORDER BY created_at DESC`,
146
146
  [tenantId, threadId]
147
147
  );
148
- return result.rows.map(this.mapRowToWorkflowRun);
148
+ return result.rows.map(mapRowToWorkflowRun);
149
149
  }
150
150
 
151
151
  async getWorkflowRunsByAssistantId(tenantId: string, assistantId: string): Promise<WorkflowRun[]> {
@@ -154,7 +154,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
154
154
  `SELECT * FROM lattice_workflow_runs WHERE tenant_id = $1 AND assistant_id = $2 ORDER BY created_at DESC`,
155
155
  [tenantId, assistantId]
156
156
  );
157
- return result.rows.map(this.mapRowToWorkflowRun);
157
+ return result.rows.map(mapRowToWorkflowRun);
158
158
  }
159
159
 
160
160
  async getWorkflowRunsByTenantId(tenantId: string): Promise<WorkflowRun[]> {
@@ -163,7 +163,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
163
163
  `SELECT * FROM lattice_workflow_runs WHERE tenant_id = $1 ORDER BY created_at DESC`,
164
164
  [tenantId]
165
165
  );
166
- return result.rows.map(this.mapRowToWorkflowRun);
166
+ return result.rows.map(mapRowToWorkflowRun);
167
167
  }
168
168
 
169
169
  async createRunStep(request: CreateRunStepRequest): Promise<RunStep> {
@@ -199,7 +199,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
199
199
  [request.runId, request.stepType, request.stepName]
200
200
  );
201
201
  if (result.rows.length > 0) {
202
- const existing = this.mapRowToRunStep(result.rows[0]);
202
+ const existing = mapRowToRunStep(result.rows[0]);
203
203
  // Update threadId if not already set and request has one
204
204
  if (!existing.threadId && request.threadId) {
205
205
  await this.pool.query(
@@ -250,7 +250,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
250
250
  `SELECT * FROM lattice_workflow_steps WHERE run_id = $1 AND id = $2`,
251
251
  [runId, stepId]
252
252
  );
253
- return row.rows.length > 0 ? this.mapRowToRunStep(row.rows[0]) : null;
253
+ return row.rows.length > 0 ? mapRowToRunStep(row.rows[0]) : null;
254
254
  }
255
255
 
256
256
  async getRunSteps(runId: string): Promise<RunStep[]> {
@@ -259,7 +259,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
259
259
  `SELECT * FROM lattice_workflow_steps WHERE run_id = $1 ORDER BY created_at ASC`,
260
260
  [runId]
261
261
  );
262
- return result.rows.map(this.mapRowToRunStep);
262
+ return result.rows.map(mapRowToRunStep);
263
263
  }
264
264
 
265
265
  async getRunStepsByType(runId: string, stepType: StepType): Promise<RunStep[]> {
@@ -268,7 +268,7 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
268
268
  `SELECT * FROM lattice_workflow_steps WHERE run_id = $1 AND step_type = $2 ORDER BY created_at ASC`,
269
269
  [runId, stepType]
270
270
  );
271
- return result.rows.map(this.mapRowToRunStep);
271
+ return result.rows.map(mapRowToRunStep);
272
272
  }
273
273
 
274
274
  async getInterruptedSteps(runId: string): Promise<RunStep[]> {
@@ -277,48 +277,59 @@ export class PostgreSQLWorkflowTrackingStore implements WorkflowTrackingStore {
277
277
  `SELECT * FROM lattice_workflow_steps WHERE run_id = $1 AND status = 'interrupted' ORDER BY created_at ASC`,
278
278
  [runId]
279
279
  );
280
- return result.rows.map(this.mapRowToRunStep);
280
+ return result.rows.map(mapRowToRunStep);
281
281
  }
282
+ }
282
283
 
283
- private mapRowToWorkflowRun(row: any): WorkflowRun {
284
- return {
285
- id: row.id,
286
- tenantId: row.tenant_id,
287
- assistantId: row.assistant_id,
288
- threadId: row.thread_id,
289
- status: row.status as WorkflowRunStatus,
290
- topologyEdges: typeof row.topology_edges === 'string' ? JSON.parse(row.topology_edges) : row.topology_edges || [],
291
- totalEdges: row.total_edges,
292
- completedEdges: row.completed_edges,
293
- errorMessage: row.error_message,
294
- metadata: typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata || {},
295
- startedAt: row.started_at,
296
- completedAt: row.completed_at,
297
- createdAt: row.created_at,
298
- updatedAt: row.updated_at,
299
- };
284
+ export function safeParse<T>(value: unknown, fallback: T): T {
285
+ if (value === null || value === undefined) return fallback;
286
+ if (typeof value !== 'string') return value as unknown as T;
287
+ try {
288
+ return JSON.parse(value) as T;
289
+ } catch {
290
+ return fallback;
300
291
  }
292
+ }
301
293
 
302
- private mapRowToRunStep(row: any): RunStep {
303
- return {
304
- id: row.id,
305
- runId: row.run_id,
306
- tenantId: row.tenant_id,
307
- stepType: row.step_type as StepType,
308
- stepName: row.step_name,
309
- threadId: row.thread_id,
310
- edgeFrom: row.edge_from,
311
- edgeTo: row.edge_to,
312
- edgePurpose: row.edge_purpose,
313
- input: typeof row.input === 'string' ? JSON.parse(row.input) : row.input,
314
- output: typeof row.output === 'string' ? JSON.parse(row.output) : row.output,
315
- status: row.status,
316
- errorMessage: row.error_message,
317
- startedAt: row.started_at,
318
- completedAt: row.completed_at,
319
- durationMs: row.duration_ms,
320
- createdAt: row.created_at,
321
- updatedAt: row.updated_at,
322
- };
323
- }
294
+ export function mapRowToWorkflowRun(row: any): WorkflowRun {
295
+ return {
296
+ id: row.id,
297
+ tenantId: row.tenant_id,
298
+ assistantId: row.assistant_id,
299
+ threadId: row.thread_id,
300
+ status: row.status as WorkflowRunStatus,
301
+ topologyEdges: safeParse(row.topology_edges, []),
302
+ totalEdges: row.total_edges,
303
+ completedEdges: row.completed_edges,
304
+ errorMessage: row.error_message,
305
+ metadata: safeParse(row.metadata, {}),
306
+ startedAt: row.started_at,
307
+ completedAt: row.completed_at,
308
+ createdAt: row.created_at,
309
+ updatedAt: row.updated_at,
310
+ };
324
311
  }
312
+
313
+ export function mapRowToRunStep(row: any): RunStep {
314
+ return {
315
+ id: row.id,
316
+ runId: row.run_id,
317
+ tenantId: row.tenant_id,
318
+ stepType: row.step_type as StepType,
319
+ stepName: row.step_name,
320
+ threadId: row.thread_id,
321
+ edgeFrom: row.edge_from,
322
+ edgeTo: row.edge_to,
323
+ edgePurpose: row.edge_purpose,
324
+ input: safeParse(row.input, undefined),
325
+ output: safeParse(row.output, undefined),
326
+ status: row.status,
327
+ errorMessage: row.error_message,
328
+ startedAt: row.started_at,
329
+ completedAt: row.completed_at,
330
+ durationMs: row.duration_ms,
331
+ createdAt: row.created_at,
332
+ updatedAt: row.updated_at,
333
+ };
334
+ }
335
+