@axiom-lattice/pg-stores 2.0.10 → 3.0.1

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.
Files changed (30) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +60 -0
  3. package/dist/index.d.mts +52 -4
  4. package/dist/index.d.ts +52 -4
  5. package/dist/index.js +907 -123
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +905 -123
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/PostgreSQLA2AApiKeyStore.migrations.test.ts +76 -0
  11. package/src/__tests__/PostgreSQLA2AApiKeyStore.test.ts +133 -0
  12. package/src/__tests__/PostgreSQLAgentWebAppStore.migrations.test.ts +77 -0
  13. package/src/__tests__/PostgreSQLAgentWebAppStore.test.ts +290 -0
  14. package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +37 -2
  15. package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +90 -0
  16. package/src/__tests__/PostgreSQLTaskStore.test.ts +287 -0
  17. package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +104 -0
  18. package/src/__tests__/add_a2a_key_assistant_ids.test.ts +34 -0
  19. package/src/__tests__/workspace-project-store.test.ts +48 -0
  20. package/src/createPgStoreConfig.ts +10 -1
  21. package/src/index.ts +10 -0
  22. package/src/migrations/add_a2a_key_assistant_ids.ts +24 -0
  23. package/src/migrations/add_project_kind_column.ts +29 -0
  24. package/src/migrations/agent_web_apps_migration.ts +37 -0
  25. package/src/migrations/task_work_items_migration.ts +15 -0
  26. package/src/stores/PostgreSQLA2AApiKeyStore.ts +23 -8
  27. package/src/stores/PostgreSQLAgentWebAppStore.ts +280 -0
  28. package/src/stores/PostgreSQLProjectStore.ts +31 -8
  29. package/src/stores/PostgreSQLTaskStore.ts +272 -7
  30. package/src/stores/PostgreSQLTaskWorkItemStore.ts +42 -2
@@ -0,0 +1,90 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Pool } from "pg";
3
+ import { PostgreSQLTaskStore } from "../stores/PostgreSQLTaskStore";
4
+
5
+ const databaseUrl = process.env.TEST_DATABASE_URL;
6
+ const describeIfDatabase = databaseUrl ? describe : describe.skip;
7
+ const CANONICAL_UTC_MILLIS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
8
+
9
+ describeIfDatabase("PostgreSQLTaskStore timestamp contract", () => {
10
+ let pool: Pool;
11
+ let store: PostgreSQLTaskStore;
12
+ const tenantId = `timestamp-contract-${randomUUID()}`;
13
+
14
+ beforeAll(async () => {
15
+ pool = new Pool({ connectionString: databaseUrl });
16
+ store = new PostgreSQLTaskStore({ poolConfig: databaseUrl });
17
+ await store.initialize();
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await pool.query("DELETE FROM lattice_tasks WHERE tenant_id = $1", [tenantId]);
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await store.dispose();
26
+ await pool.end();
27
+ });
28
+
29
+ it("updates canonical VARCHAR timestamps monotonically as UTC ISO text", async () => {
30
+ const task = await store.create({
31
+ tenantId,
32
+ ownerType: "user",
33
+ ownerId: "user-1",
34
+ title: "Canonical timestamp",
35
+ });
36
+
37
+ const updated = await store.update(tenantId, task.id, { metadata: { trainingRound: true } });
38
+ const row = await pool.query<{ updated_at: string }>(
39
+ "SELECT updated_at FROM lattice_tasks WHERE tenant_id = $1 AND id = $2",
40
+ [tenantId, task.id],
41
+ );
42
+
43
+ expect(updated!.updatedAt.getTime()).toBeGreaterThan(task.updatedAt.getTime());
44
+ expect(row.rows[0].updated_at).toMatch(CANONICAL_UTC_MILLIS);
45
+ });
46
+
47
+ it("repairs shape-valid impossible and plainly malformed timestamps without cast errors", async () => {
48
+ const task = await store.create({
49
+ tenantId,
50
+ ownerType: "user",
51
+ ownerId: "user-1",
52
+ title: "Repair timestamp",
53
+ });
54
+
55
+ for (const malformed of ["2026-99-99T00:00:00.000Z", "not-a-date"]) {
56
+ await pool.query(
57
+ "UPDATE lattice_tasks SET updated_at = $1 WHERE tenant_id = $2 AND id = $3",
58
+ [malformed, tenantId, task.id],
59
+ );
60
+
61
+ await expect(store.update(tenantId, task.id, { metadata: { repaired: malformed } })).resolves.toBeTruthy();
62
+ const row = await pool.query<{ updated_at: string }>(
63
+ "SELECT updated_at FROM lattice_tasks WHERE tenant_id = $1 AND id = $2",
64
+ [tenantId, task.id],
65
+ );
66
+ expect(row.rows[0].updated_at).toMatch(CANONICAL_UTC_MILLIS);
67
+ }
68
+ });
69
+
70
+ it("makes snapshot CAS safely miss malformed stored timestamps", async () => {
71
+ const task = await store.create({
72
+ tenantId,
73
+ ownerType: "user",
74
+ ownerId: "user-1",
75
+ title: "Malformed snapshot",
76
+ });
77
+ await pool.query(
78
+ "UPDATE lattice_tasks SET updated_at = $1 WHERE tenant_id = $2 AND id = $3",
79
+ ["2026-02-31T00:00:00.000Z", tenantId, task.id],
80
+ );
81
+
82
+ await expect(store.updateIfStatusAndUpdatedAt(
83
+ tenantId,
84
+ task.id,
85
+ { description: "must not write" },
86
+ ["pending"],
87
+ task.updatedAt,
88
+ )).resolves.toBeNull();
89
+ });
90
+ });
@@ -0,0 +1,287 @@
1
+ /**
2
+ * PostgreSQLTaskStore.create tests
3
+ *
4
+ * Verifies create() honors a caller-provided id (single-ID mapping
5
+ * with external systems such as A2A) and falls back to a generated
6
+ * UUID when omitted.
7
+ */
8
+
9
+ import { describe, expect, it, jest, beforeEach } from "@jest/globals";
10
+ import type { Pool } from "pg";
11
+
12
+ const mockQuery = jest.fn();
13
+
14
+ import { PostgreSQLTaskStore } from "../stores/PostgreSQLTaskStore";
15
+
16
+ const fullRow = (id: string) => ({
17
+ id,
18
+ tenant_id: "t1",
19
+ owner_type: "agent",
20
+ owner_id: "owner-1",
21
+ title: "t",
22
+ description: null,
23
+ status: "pending",
24
+ priority: "medium",
25
+ due_date: null,
26
+ metadata: null,
27
+ parent_id: null,
28
+ source_id: null,
29
+ context: null,
30
+ require_review: null,
31
+ dependencies: null,
32
+ result: null,
33
+ failure_reason: null,
34
+ workspace_id: null,
35
+ project_id: null,
36
+ files: null,
37
+ created_at: "2026-01-01T00:00:00.000Z",
38
+ updated_at: "2026-01-01T00:00:00.000Z",
39
+ });
40
+
41
+ describe("PostgreSQLTaskStore.create", () => {
42
+ const pool = { query: mockQuery } as unknown as Pool;
43
+
44
+ beforeEach(() => {
45
+ jest.clearAllMocks();
46
+ });
47
+
48
+ it("honors a caller-provided id", async () => {
49
+ const store = new PostgreSQLTaskStore({ pool });
50
+ mockQuery
51
+ .mockResolvedValueOnce({ rows: [] }) // INSERT
52
+ .mockResolvedValueOnce({ rows: [fullRow("a2a-task-1")] }); // getById
53
+
54
+ const result = await store.create({
55
+ id: "a2a-task-1",
56
+ tenantId: "t1",
57
+ ownerType: "agent",
58
+ ownerId: "owner-1",
59
+ title: "t",
60
+ });
61
+
62
+ expect(result.id).toBe("a2a-task-1");
63
+ const insertParams = mockQuery.mock.calls[0][1] as unknown[];
64
+ expect(insertParams[0]).toBe("a2a-task-1");
65
+ });
66
+
67
+ it("generates a uuid when id is omitted", async () => {
68
+ const store = new PostgreSQLTaskStore({ pool });
69
+ mockQuery
70
+ .mockResolvedValueOnce({ rows: [] }) // INSERT
71
+ .mockResolvedValueOnce({ rows: [fullRow("some-uuid")] }); // getById
72
+
73
+ await store.create({
74
+ tenantId: "t1",
75
+ ownerType: "agent",
76
+ ownerId: "owner-1",
77
+ title: "t",
78
+ });
79
+
80
+ const insertParams = mockQuery.mock.calls[0][1] as unknown[];
81
+ expect(insertParams[0]).toMatch(
82
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
83
+ );
84
+ });
85
+
86
+ it("uses one conditional UPDATE that blocks current statuses", async () => {
87
+ const store = new PostgreSQLTaskStore({ pool });
88
+ mockQuery.mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 });
89
+
90
+ const result = await store.updateIfStatusNotIn(
91
+ "t1",
92
+ "task-1",
93
+ { status: "completed", result: "done" },
94
+ ["cancelled"],
95
+ );
96
+
97
+ expect(result?.id).toBe("task-1");
98
+ expect(mockQuery).toHaveBeenCalledTimes(1);
99
+ const [sql, params] = mockQuery.mock.calls[0] as unknown as [string, unknown[]];
100
+ expect(sql).toContain("status <> ALL");
101
+ expect(sql).toContain("RETURNING *");
102
+ expect(params).toContainEqual(["cancelled"]);
103
+ expect(sql).toContain("updated_at::timestamptz + interval '1 millisecond'");
104
+ });
105
+
106
+ it("casts varchar updated_at before interval arithmetic on ordinary metadata updates", async () => {
107
+ const store = new PostgreSQLTaskStore({ pool });
108
+ mockQuery
109
+ .mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 })
110
+ .mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 });
111
+
112
+ await store.update("t1", "task-1", { metadata: { trainingRound: true } });
113
+
114
+ const updateSql = mockQuery.mock.calls[1][0] as string;
115
+ expect(updateSql).toContain("updated_at::timestamptz + interval '1 millisecond'");
116
+ expect(updateSql).toContain("to_char");
117
+ expect(updateSql).toMatch(/CASE\s+WHEN updated_at ~/);
118
+ expect(updateSql).toContain("substring(updated_at FROM 6 FOR 2)::integer BETWEEN 1 AND 12");
119
+ expect(updateSql).toContain("substring(updated_at FROM 9 FOR 2)::integer BETWEEN 1 AND CASE");
120
+ expect(updateSql).toContain("updated_at <> '9999-12-31T23:59:59.999Z'");
121
+ expect(updateSql).not.toContain("updated_at + interval '1 millisecond'");
122
+ expect(updateSql).toContain("RETURNING *");
123
+ });
124
+
125
+ it("returns null when ordinary update cannot advance the maximum timestamp", async () => {
126
+ const store = new PostgreSQLTaskStore({ pool });
127
+ mockQuery
128
+ .mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 })
129
+ .mockResolvedValueOnce({ rows: [], rowCount: 0 });
130
+
131
+ await expect(store.update("t1", "task-1", { title: "Cannot advance" })).resolves.toBeNull();
132
+ });
133
+
134
+ it("treats context undefined as an ordinary update no-op", async () => {
135
+ const store = new PostgreSQLTaskStore({ pool });
136
+ mockQuery.mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 });
137
+
138
+ const result = await store.update("t1", "task-1", { context: undefined });
139
+
140
+ expect(result?.id).toBe("task-1");
141
+ expect(mockQuery).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it("returns null when the conditional update is blocked", async () => {
145
+ const store = new PostgreSQLTaskStore({ pool });
146
+ mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 });
147
+
148
+ await expect(
149
+ store.updateIfStatusNotIn("t1", "task-1", { status: "completed" }, ["cancelled"]),
150
+ ).resolves.toBeNull();
151
+ });
152
+
153
+ it("uses one conditional UPDATE restricted to expected statuses", async () => {
154
+ const store = new PostgreSQLTaskStore({ pool });
155
+ mockQuery.mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 });
156
+
157
+ await store.updateIfStatusIn(
158
+ "t1", "task-1", { status: "completed" }, ["in_progress", "interrupted"],
159
+ );
160
+
161
+ expect(mockQuery).toHaveBeenCalledTimes(1);
162
+ const [sql, params] = mockQuery.mock.calls[0] as unknown as [string, unknown[]];
163
+ expect(sql).toContain("status = ANY");
164
+ expect(sql).toContain("RETURNING *");
165
+ expect(params).toContainEqual(["in_progress", "interrupted"]);
166
+ expect(sql).toContain("updated_at::timestamptz + interval '1 millisecond'");
167
+ });
168
+
169
+ it("returns null when no expected status matches or the task is missing", async () => {
170
+ const store = new PostgreSQLTaskStore({ pool });
171
+ mockQuery.mockResolvedValue({ rows: [], rowCount: 0 });
172
+
173
+ await expect(store.updateIfStatusIn(
174
+ "t1", "task-1", { status: "completed" }, ["in_progress"],
175
+ )).resolves.toBeNull();
176
+ await expect(store.updateIfStatusIn(
177
+ "t1", "missing", { status: "completed" }, ["in_progress"],
178
+ )).resolves.toBeNull();
179
+ });
180
+
181
+ it("uses one conditional UPDATE restricted to status and updatedAt snapshot", async () => {
182
+ const store = new PostgreSQLTaskStore({ pool });
183
+ mockQuery.mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 });
184
+ const snapshot = new Date("2026-01-01T00:00:00.000Z");
185
+
186
+ const result = await store.updateIfStatusAndUpdatedAt(
187
+ "t1", "task-1", { status: "completed" }, ["in_progress"], snapshot,
188
+ );
189
+
190
+ expect(result?.id).toBe("task-1");
191
+ expect(mockQuery).toHaveBeenCalledTimes(1);
192
+ const [sql, params] = mockQuery.mock.calls[0] as unknown as [string, unknown[]];
193
+ expect(sql).toContain("status = ANY");
194
+ expect(sql).toContain("updated_at =");
195
+ expect(sql).toContain("date_trunc('milliseconds'");
196
+ expect(sql).toContain("updated_at::timestamptz + interval '1 millisecond'");
197
+ expect(sql).toMatch(/CASE WHEN CASE\s+WHEN updated_at ~/);
198
+ expect(sql).toContain("THEN updated_at::timestamptz =");
199
+ expect(sql).toContain("ELSE FALSE");
200
+ expect(sql).toContain("RETURNING *");
201
+ expect(params).toContain(snapshot.toISOString());
202
+ });
203
+
204
+ it("returns null when the status matches but updatedAt snapshot is stale", async () => {
205
+ const store = new PostgreSQLTaskStore({ pool });
206
+ mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 });
207
+
208
+ await expect(store.updateIfStatusAndUpdatedAt(
209
+ "t1", "task-1", { description: "stale overwrite" }, ["in_progress"], "2026-01-01T00:00:00.000Z",
210
+ )).resolves.toBeNull();
211
+ });
212
+
213
+ it("lets only one caller update the same snapshot", async () => {
214
+ const store = new PostgreSQLTaskStore({ pool });
215
+ mockQuery
216
+ .mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 })
217
+ .mockResolvedValueOnce({ rows: [], rowCount: 0 });
218
+ const snapshot = "2026-01-01T00:00:00.000Z";
219
+
220
+ const [first, second] = await Promise.all([
221
+ store.updateIfStatusAndUpdatedAt("t1", "task-1", { description: "first" }, ["in_progress"], snapshot),
222
+ store.updateIfStatusAndUpdatedAt("t1", "task-1", { description: "second" }, ["in_progress"], snapshot),
223
+ ]);
224
+
225
+ expect(first?.id).toBe("task-1");
226
+ expect(second).toBeNull();
227
+ });
228
+
229
+ it("passes null lifecycle outputs through a snapshot update as SQL NULL", async () => {
230
+ const store = new PostgreSQLTaskStore({ pool });
231
+ mockQuery.mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 });
232
+
233
+ await store.updateIfStatusAndUpdatedAt(
234
+ "t1",
235
+ "task-1",
236
+ { result: null, failureReason: null, context: null },
237
+ ["failed"],
238
+ "2026-01-01T00:00:00.000Z",
239
+ );
240
+
241
+ const [sql, params] = mockQuery.mock.calls[0] as unknown as [string, unknown[]];
242
+ expect(sql).toContain("result =");
243
+ expect(sql).toContain("failure_reason =");
244
+ expect(sql).toContain("context =");
245
+ expect(params.filter((value) => value === null)).toHaveLength(3);
246
+ });
247
+
248
+ it("uses one UPDATE with exact child and parent snapshot predicates", async () => {
249
+ const store = new PostgreSQLTaskStore({ pool });
250
+ mockQuery.mockResolvedValueOnce({ rows: [fullRow("child-1")], rowCount: 1 });
251
+ const childSnapshot = "2026-01-01T00:00:00.000Z";
252
+ const parentSnapshot = "2026-01-01T00:00:01.000Z";
253
+
254
+ const result = await store.updateIfStatusUpdatedAtAndParentUpdatedAt(
255
+ "t1", "child-1", { status: "completed" }, ["in_progress"], childSnapshot,
256
+ "parent-1", parentSnapshot,
257
+ );
258
+
259
+ expect(result?.id).toBe("child-1");
260
+ expect(mockQuery).toHaveBeenCalledTimes(1);
261
+ const [sql, params] = mockQuery.mock.calls[0] as unknown as [string, unknown[]];
262
+ expect(sql).toContain("UPDATE lattice_tasks AS child");
263
+ expect(sql).toContain("EXISTS");
264
+ expect(sql).toContain("parent.tenant_id = child.tenant_id");
265
+ expect(sql).toContain("parent.id =");
266
+ expect(sql).toContain("child.updated_at::timestamptz + interval '1 millisecond'");
267
+ expect(sql).toMatch(/CASE WHEN CASE\s+WHEN child\.updated_at ~/);
268
+ expect(sql).toContain("THEN child.updated_at::timestamptz =");
269
+ expect(sql).toMatch(/CASE WHEN CASE\s+WHEN parent\.updated_at ~/);
270
+ expect(sql).toContain("THEN parent.updated_at::timestamptz =");
271
+ expect(sql.match(/<> '9999-12-31T23:59:59\.999Z'/g)).toHaveLength(2);
272
+ expect(params).toEqual(expect.arrayContaining([
273
+ "t1", "child-1", ["in_progress"], childSnapshot, "parent-1", parentSnapshot,
274
+ ]));
275
+ });
276
+
277
+ it("returns null when the parent snapshot predicate does not match", async () => {
278
+ const store = new PostgreSQLTaskStore({ pool });
279
+ mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 });
280
+
281
+ await expect(store.updateIfStatusUpdatedAtAndParentUpdatedAt(
282
+ "t1", "child-1", { status: "completed" }, ["in_progress"], "2026-01-01T00:00:00.000Z",
283
+ "missing-parent", "2026-01-01T00:00:01.000Z",
284
+ )).resolves.toBeNull();
285
+ });
286
+
287
+ });
@@ -0,0 +1,104 @@
1
+ import type { Pool } from "pg";
2
+ import { PostgreSQLTaskWorkItemStore } from "../stores/PostgreSQLTaskWorkItemStore";
3
+ import { addTaskWorkItemEventKeyMigration } from "../migrations/task_work_items_migration";
4
+
5
+ describe("PostgreSQLTaskWorkItemStore", () => {
6
+ const row = {
7
+ id: "item-1", tenant_id: "tenant-1", task_id: "task-1", event_key: "evidence:key",
8
+ action: "completion_evidence", actor: "agent:a", thread_id: null, summary: "first",
9
+ detail: null, attempt: null, workspace_id: null, project_id: null,
10
+ created_at: "2026-01-01T00:00:00.000Z",
11
+ };
12
+
13
+ it.each([
14
+ [undefined, "ASC"],
15
+ ["asc", "ASC"],
16
+ ["desc", "DESC"],
17
+ ] as const)("uses %s order as %s with deterministic IDs", async (order, sqlOrder) => {
18
+ const query = jest.fn().mockResolvedValue({ rows: [] });
19
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
20
+
21
+ await store.list({
22
+ tenantId: "tenant-1",
23
+ taskId: "task-1",
24
+ limit: 100,
25
+ ...(order && { order }),
26
+ });
27
+
28
+ expect(query).toHaveBeenCalledWith(
29
+ expect.stringContaining(`ORDER BY created_at ${sqlOrder}, id ${sqlOrder} LIMIT $3`),
30
+ ["tenant-1", "task-1", 100],
31
+ );
32
+ });
33
+
34
+ it("filters by workspaceId and projectId", async () => {
35
+ const query = jest.fn().mockResolvedValue({ rows: [] });
36
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
37
+
38
+ await store.list({
39
+ tenantId: "tenant-1",
40
+ taskId: "task-1",
41
+ workspaceId: "workspace-1",
42
+ projectId: "project-1",
43
+ order: "desc",
44
+ limit: 100,
45
+ });
46
+
47
+ expect(query).toHaveBeenCalledWith(
48
+ expect.stringContaining(
49
+ "workspace_id = $3 AND project_id = $4 ORDER BY created_at DESC, id DESC LIMIT $5",
50
+ ),
51
+ ["tenant-1", "task-1", "workspace-1", "project-1", 100],
52
+ );
53
+ });
54
+
55
+ it("looks up an event key by tenant and task", async () => {
56
+ const query = jest.fn().mockResolvedValue({ rows: [row] });
57
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
58
+
59
+ const result = await store.findByEventKey("tenant-1", "task-1", "evidence:key");
60
+
61
+ expect(query).toHaveBeenCalledWith(
62
+ expect.stringContaining("tenant_id = $1 AND task_id = $2 AND event_key = $3"),
63
+ ["tenant-1", "task-1", "evidence:key"],
64
+ );
65
+ expect(result?.eventKey).toBe("evidence:key");
66
+ });
67
+
68
+ it("maps a null event key to undefined", async () => {
69
+ const query = jest.fn().mockResolvedValue({ rows: [{ ...row, event_key: null }] });
70
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
71
+
72
+ const result = await store.list({ tenantId: "tenant-1", taskId: "task-1" });
73
+
74
+ expect(result[0].eventKey).toBeUndefined();
75
+ });
76
+
77
+ it("atomically inserts by task-scoped event key or returns the existing row", async () => {
78
+ const query = jest.fn().mockResolvedValue({ rows: [row] });
79
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
80
+
81
+ const result = await store.createIfAbsentByEventKey({
82
+ tenantId: "tenant-1", taskId: "task-1", eventKey: "evidence:key",
83
+ action: "completion_evidence", actor: "agent:a", summary: "first",
84
+ });
85
+
86
+ expect(query).toHaveBeenCalledTimes(1);
87
+ expect(query).toHaveBeenCalledWith(
88
+ expect.stringMatching(/INSERT[\s\S]+ON CONFLICT \(tenant_id, task_id, event_key\)[\s\S]+RETURNING/),
89
+ expect.arrayContaining(["tenant-1", "task-1", "evidence:key"]),
90
+ );
91
+ expect(result.eventKey).toBe("evidence:key");
92
+ });
93
+
94
+ it("migrates a task-scoped unique event key", async () => {
95
+ const query = jest.fn().mockResolvedValue({ rows: [] });
96
+
97
+ await addTaskWorkItemEventKeyMigration.up({ query } as never);
98
+
99
+ expect(query).toHaveBeenCalledWith(expect.stringContaining("ADD COLUMN IF NOT EXISTS event_key TEXT"));
100
+ expect(query).toHaveBeenCalledWith(expect.stringContaining(
101
+ "(tenant_id, task_id, event_key) WHERE event_key IS NOT NULL",
102
+ ));
103
+ });
104
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * add_a2a_key_assistant_ids migration tests
3
+ *
4
+ * Verifies the migration adds (and removes) the assistant_ids JSONB
5
+ * column on lattice_a2a_api_keys.
6
+ */
7
+
8
+ import { describe, expect, it, jest } from "@jest/globals";
9
+ import type { PoolClient } from "pg";
10
+ import { addA2AKeyAssistantIds } from "../migrations/add_a2a_key_assistant_ids";
11
+
12
+ describe("add_a2a_key_assistant_ids migration", () => {
13
+ it("has name add_a2a_key_assistant_ids", () => {
14
+ expect(addA2AKeyAssistantIds.name).toBe("add_a2a_key_assistant_ids");
15
+ });
16
+
17
+ it("has a version greater than the current max (166)", () => {
18
+ expect(addA2AKeyAssistantIds.version).toBe(167);
19
+ });
20
+
21
+ it("emits ADD COLUMN assistant_ids JSONB in up()", async () => {
22
+ const queries: string[] = [];
23
+ const client = { query: jest.fn(async (sql: string) => { queries.push(sql); }) };
24
+ await addA2AKeyAssistantIds.up(client as unknown as PoolClient);
25
+ expect(queries.some((q) => q.includes("ADD COLUMN IF NOT EXISTS assistant_ids JSONB"))).toBe(true);
26
+ });
27
+
28
+ it("emits DROP COLUMN assistant_ids in down()", async () => {
29
+ const queries: string[] = [];
30
+ const client = { query: jest.fn(async (sql: string) => { queries.push(sql); }) };
31
+ await addA2AKeyAssistantIds.down?.(client as unknown as PoolClient);
32
+ expect(queries.some((q) => q.includes("DROP COLUMN IF EXISTS assistant_ids"))).toBe(true);
33
+ });
34
+ });
@@ -232,4 +232,52 @@ describeIfDb("PostgreSQLProjectStore", () => {
232
232
  const deleted = await store.deleteProject(tenantId, "non-existent");
233
233
  expect(deleted).toBe(false);
234
234
  });
235
+
236
+ test("should default kind to 'business' when omitted on create", async () => {
237
+ const project = await store.createProject(tenantId, workspaceId, "test-proj-kind-1", {
238
+ name: "Default Kind",
239
+ });
240
+ expect(project.kind).toBe("business");
241
+
242
+ const fetched = await store.getProjectById(tenantId, "test-proj-kind-1");
243
+ expect(fetched?.kind).toBe("business");
244
+ });
245
+
246
+ test("should round-trip an explicit kind through create and get", async () => {
247
+ const project = await store.createProject(tenantId, workspaceId, "test-proj-kind-2", {
248
+ name: "Training Project",
249
+ kind: "training",
250
+ });
251
+ expect(project.kind).toBe("training");
252
+
253
+ const fetched = await store.getProjectById(tenantId, "test-proj-kind-2");
254
+ expect(fetched?.kind).toBe("training");
255
+ });
256
+
257
+ test("should update kind", async () => {
258
+ await store.createProject(tenantId, workspaceId, "test-proj-kind-3", {
259
+ name: "Reclassified",
260
+ });
261
+
262
+ const updated = await store.updateProject(tenantId, "test-proj-kind-3", { kind: "personal" });
263
+ expect(updated?.kind).toBe("personal");
264
+ });
265
+
266
+ test("should filter projects by kind", async () => {
267
+ await store.createProject(tenantId, workspaceId, "test-proj-kind-4", { name: "Biz", kind: "business" });
268
+ await store.createProject(tenantId, workspaceId, "test-proj-kind-5", { name: "Train", kind: "training" });
269
+
270
+ const training = await store.getProjectsByWorkspace(tenantId, workspaceId, { kind: "training" });
271
+ const ids = training.filter((p) => p.id.startsWith("test-proj-kind-")).map((p) => p.id);
272
+ expect(ids).toEqual(["test-proj-kind-5"]);
273
+ });
274
+
275
+ test("should return all projects when kind filter is omitted", async () => {
276
+ await store.createProject(tenantId, workspaceId, "test-proj-kind-6", { name: "Biz", kind: "business" });
277
+ await store.createProject(tenantId, workspaceId, "test-proj-kind-7", { name: "Train", kind: "training" });
278
+
279
+ const all = await store.getProjectsByWorkspace(tenantId, workspaceId);
280
+ const ids = all.filter((p) => p.id.startsWith("test-proj-kind-")).map((p) => p.id);
281
+ expect(ids).toHaveLength(2);
282
+ });
235
283
  });
@@ -17,6 +17,7 @@ import { ThreadMessageQueueStore } from "./stores/ThreadMessageQueueStore";
17
17
  import { ChannelBindingStore } from "./stores/ChannelBindingStore";
18
18
  import { PostgreSQLChannelInstallationStore } from "./stores/PostgreSQLChannelInstallationStore";
19
19
  import { PostgreSQLA2AApiKeyStore } from "./stores/PostgreSQLA2AApiKeyStore";
20
+ import { PostgreSQLAgentWebAppStore } from "./stores/PostgreSQLAgentWebAppStore";
20
21
  import { PostgreSQLScheduleStorage } from "./stores/PostgreSQLScheduleStorage";
21
22
  import { PostgreSQLTaskStore } from "./stores/PostgreSQLTaskStore";
22
23
  import { PostgreSQLTaskWorkItemStore } from "./stores/PostgreSQLTaskWorkItemStore";
@@ -43,6 +44,7 @@ import { changeSkillPrimaryKey } from "./migrations/skill_pk_migration";
43
44
  import { createWorkspacesTable } from "./migrations/workspace_migrations";
44
45
  import { createProjectsTable } from "./migrations/project_migrations";
45
46
  import { addProjectConfigColumn } from "./migrations/add_project_config_column";
47
+ import { addProjectKindColumn } from "./migrations/add_project_kind_column";
46
48
  import { createUsersTable } from "./migrations/user_migrations";
47
49
  import { addUserStatusColumn } from "./migrations/user_status_migration";
48
50
  import { createTenantsTable } from "./migrations/tenant_migrations";
@@ -63,12 +65,14 @@ import { addWorkspaceProjectToQueue } from "./migrations/add_workspace_project_t
63
65
  import { createWorkflowTrackingTables, addStepThreadId, addWorkflowRunsTenantStatusUpdatedIndex } from "./migrations/workflow_tracking_migrations";
64
66
  import { evalMigrations } from "./migrations/eval_migrations";
65
67
  import { createA2AApiKeysTable } from "./migrations/a2a_api_key_migration";
68
+ import { addA2AKeyAssistantIds } from "./migrations/add_a2a_key_assistant_ids";
66
69
  import { taskMigrations } from "./migrations/task_migration";
67
70
  import { createMenuItemsTable } from "./migrations/menu_items_migration";
68
71
  import { addFileContentType } from "./migrations/menu_items_add_file_type";
69
72
  import { createSharedResourcesTable } from "./migrations/shared_resources_migration";
70
73
  import { createCollectionsTable } from "./migrations/collection_migrations";
71
- import { createTaskWorkItemsMigration, addWorkItemProjectFieldsMigration } from "./migrations/task_work_items_migration";
74
+ import { createTaskWorkItemsMigration, addWorkItemProjectFieldsMigration, addTaskWorkItemEventKeyMigration } from "./migrations/task_work_items_migration";
75
+ import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
72
76
 
73
77
  export async function createPgStoreConfig(connectionString: string) {
74
78
  const pool = new Pool({ connectionString });
@@ -126,6 +130,10 @@ export async function createPgStoreConfig(connectionString: string) {
126
130
  mm.register(addWorkItemProjectFieldsMigration); // v140
127
131
  mm.register(createConnectionConfigsTable); // v160
128
132
  mm.register(addWorkflowRunsTenantStatusUpdatedIndex); // v161
133
+ mm.register(addProjectKindColumn); // v165
134
+ mm.register(addA2AKeyAssistantIds); // v167
135
+ mm.register(addTaskWorkItemEventKeyMigration); // v168
136
+ mm.register(createAgentWebAppsTable); // v169
129
137
 
130
138
  await mm.migrate();
131
139
 
@@ -158,6 +166,7 @@ export async function createPgStoreConfig(connectionString: string) {
158
166
  task: new PostgreSQLTaskStore(opts),
159
167
  taskWorkItem: taskWorkItemStore,
160
168
  a2aApiKey: new PostgreSQLA2AApiKeyStore(opts),
169
+ agentWebApp: new PostgreSQLAgentWebAppStore(opts),
161
170
  schedule: new PostgreSQLScheduleStorage(opts),
162
171
  menu: new MenuStore(opts),
163
172
  sharedResource: new PostgresSharedResourceStore(opts),
package/src/index.ts CHANGED
@@ -50,12 +50,14 @@ export * from "./migrations/channel_installations_alter_migration";
50
50
  export * from "./migrations/channel_bindings_migration";
51
51
  export * from "./migrations/shared_resources_migration";
52
52
  export * from "./migrations/a2a_api_key_migration";
53
+ export * from "./migrations/agent_web_apps_migration";
53
54
  export * from "./migrations/workflow_tracking_migrations";
54
55
  export * from "./stores/ChannelBindingStore";
55
56
  export * from "./stores/ThreadMessageQueueStore";
56
57
  export * from "./stores/ChannelIdentityMappingStore";
57
58
  export * from "./stores/PostgreSQLChannelInstallationStore";
58
59
  export * from "./stores/PostgreSQLA2AApiKeyStore";
60
+ export * from "./stores/PostgreSQLAgentWebAppStore";
59
61
  export * from "./stores/PostgreSQLWorkflowTrackingStore";
60
62
  export * from "./stores/PostgreSQLEvalStore";
61
63
  export * from "./migrations/eval_migrations";
@@ -181,4 +183,12 @@ export type {
181
183
  ShareVisibility,
182
184
  CreateShareRequest,
183
185
  ShareResult,
186
+ AgentWebAppStore,
187
+ AgentWebApp,
188
+ AgentWebAppStatus,
189
+ AgentWebAppScope,
190
+ AgentWebAppFeatures,
191
+ AgentWebAppAppearance,
192
+ CreateAgentWebAppInput,
193
+ UpdateAgentWebAppInput,
184
194
  } from "@axiom-lattice/protocols";
@@ -0,0 +1,24 @@
1
+ import type { Migration } from "./migration";
2
+ import type { PoolClient } from "pg";
3
+
4
+ /**
5
+ * Adds the assistant_ids JSONB whitelist column to A2A API keys.
6
+ *
7
+ * Empty/undefined assistant_ids means all exposed agents in the tenant.
8
+ */
9
+ export const addA2AKeyAssistantIds: Migration = {
10
+ version: 167,
11
+ name: "add_a2a_key_assistant_ids",
12
+ up: async (client: PoolClient) => {
13
+ await client.query(`
14
+ ALTER TABLE lattice_a2a_api_keys
15
+ ADD COLUMN IF NOT EXISTS assistant_ids JSONB
16
+ `);
17
+ },
18
+ down: async (client: PoolClient) => {
19
+ await client.query(`
20
+ ALTER TABLE lattice_a2a_api_keys
21
+ DROP COLUMN IF EXISTS assistant_ids
22
+ `);
23
+ },
24
+ };