@axiom-lattice/pg-stores 3.1.1 → 3.1.2

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 (41) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.d.mts +195 -15
  4. package/dist/index.d.ts +195 -15
  5. package/dist/index.js +1309 -199
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1295 -191
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/ChannelBindingStore.test.ts +122 -0
  11. package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
  12. package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
  13. package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
  14. package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
  15. package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
  16. package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
  17. package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
  18. package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
  19. package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +74 -0
  20. package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
  21. package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +96 -0
  22. package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
  23. package/src/__tests__/ThreadMessageQueueStore.test.ts +113 -0
  24. package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
  25. package/src/__tests__/task-files.test.ts +4 -3
  26. package/src/createPgStoreConfig.ts +14 -0
  27. package/src/index.ts +7 -0
  28. package/src/migrations/add_trusted_run_context_column.ts +18 -0
  29. package/src/migrations/migration.ts +2 -1
  30. package/src/migrations/project_room_migration.ts +128 -0
  31. package/src/migrations/task_migration.ts +15 -0
  32. package/src/migrations/task_work_items_migration.ts +29 -1
  33. package/src/stores/ChannelBindingStore.ts +99 -59
  34. package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
  35. package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
  36. package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
  37. package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
  38. package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
  39. package/src/stores/PostgreSQLTaskStore.ts +89 -3
  40. package/src/stores/PostgreSQLTaskWorkItemStore.ts +159 -8
  41. package/src/stores/ThreadMessageQueueStore.ts +38 -9
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+ import type { PoolClient } from "pg";
3
+
4
+ const registeredMigrationNames: string[] = [];
5
+
6
+ jest.mock("@axiom-lattice/core", () => ({}));
7
+
8
+ jest.mock("pg", () => ({
9
+ Pool: class Pool {},
10
+ }));
11
+
12
+ jest.mock("../migrations/migration", () => ({
13
+ MigrationManager: class MigrationManager {
14
+ register(migration: { name: string }): void {
15
+ registeredMigrationNames.push(migration.name);
16
+ }
17
+
18
+ async migrate(): Promise<void> {
19
+ throw new Error("stop after migrations");
20
+ }
21
+ },
22
+ }));
23
+
24
+ import { createPgStoreConfig } from "../createPgStoreConfig";
25
+ import { createProjectRoomTables } from "../migrations/project_room_migration";
26
+
27
+ function normalizeSql(sql: string): string {
28
+ return sql.replace(/\s+/g, " ").trim();
29
+ }
30
+
31
+ describe("project room migration", () => {
32
+ it("has a globally unique identity and is centrally registered before migration", async () => {
33
+ expect(createProjectRoomTables.version).toBe(172);
34
+ expect(createProjectRoomTables.name).toBe("create_project_room_tables");
35
+
36
+ registeredMigrationNames.length = 0;
37
+ await expect(createPgStoreConfig("postgres://unused")).rejects.toThrow("stop after migrations");
38
+
39
+ expect(registeredMigrationNames).toContain("create_project_room_tables");
40
+ expect(registeredMigrationNames.filter((name) => name === "create_project_room_tables")).toHaveLength(1);
41
+ });
42
+
43
+ it("creates the four tables with their checks, uniqueness rules, and query indexes", async () => {
44
+ const statements: string[] = [];
45
+ const client = {
46
+ query: async (sql: string) => {
47
+ statements.push(normalizeSql(sql));
48
+ return { rows: [] };
49
+ },
50
+ } as unknown as PoolClient;
51
+
52
+ await createProjectRoomTables.up(client);
53
+ const sql = statements.join("\n");
54
+
55
+ expect(sql).toContain("CREATE TABLE IF NOT EXISTS lattice_project_rooms");
56
+ expect(sql).toContain("PRIMARY KEY (tenant_id, id)");
57
+ expect(sql).toContain("UNIQUE (tenant_id, project_id, type)");
58
+ expect(sql).toContain("CHECK (type IN ('main'))");
59
+
60
+ expect(sql).toContain("CREATE TABLE IF NOT EXISTS lattice_project_memberships");
61
+ expect(sql).toContain("CHECK (role IN ('owner', 'admin', 'member', 'viewer'))");
62
+ expect(sql).toContain("CHECK (status IN ('active', 'removed'))");
63
+ expect(sql).toContain("UNIQUE (tenant_id, project_id, user_id)");
64
+ expect(sql).toContain("idx_lattice_project_memberships_project");
65
+
66
+ expect(sql).toContain("CREATE TABLE IF NOT EXISTS lattice_project_bot_memberships");
67
+ expect(sql).toContain("CHECK (role IN ('coordinator', 'specialist'))");
68
+ expect(sql).toContain("CHECK (status IN ('active', 'paused', 'removed'))");
69
+ expect(sql).toContain("UNIQUE (tenant_id, project_id, assistant_id)");
70
+ expect(sql).toContain("idx_lattice_project_bot_memberships_project");
71
+ expect(sql).toContain("idx_lattice_project_bot_memberships_room");
72
+ expect(sql).toContain("UNIQUE INDEX IF NOT EXISTS uq_lattice_project_bot_memberships_coordinator");
73
+ expect(sql).toContain("ON lattice_project_bot_memberships (tenant_id, project_id) WHERE role = 'coordinator' AND status IN ('active', 'paused')");
74
+ expect(sql).toContain("UNIQUE INDEX IF NOT EXISTS uq_lattice_project_bot_memberships_mention");
75
+ expect(sql).toContain("ON lattice_project_bot_memberships (tenant_id, room_id, mention_name) WHERE status IN ('active', 'paused')");
76
+
77
+ expect(sql).toContain("CREATE TABLE IF NOT EXISTS lattice_project_room_messages");
78
+ expect(sql).toContain("author JSONB NOT NULL");
79
+ expect(sql).toContain("content JSONB NOT NULL");
80
+ expect(sql).toContain("mentions JSONB NOT NULL DEFAULT '[]'::jsonb");
81
+ expect(sql).toContain("CHECK (source IN ('user', 'agent', 'task', 'routine', 'system'))");
82
+ expect(sql).toContain("UNIQUE INDEX IF NOT EXISTS uq_lattice_project_room_messages_idempotency");
83
+ expect(sql).toContain("ON lattice_project_room_messages (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL");
84
+ expect(sql).toContain("idx_lattice_project_room_messages_room_created");
85
+ expect(sql).toContain("ON lattice_project_room_messages (tenant_id, room_id, created_at DESC, id DESC)");
86
+ expect(sql).not.toMatch(/REFERENCES\s/i);
87
+ });
88
+
89
+ it("drops indexes and tables in reverse dependency order", async () => {
90
+ const statements: string[] = [];
91
+ const client = {
92
+ query: async (sql: string) => {
93
+ statements.push(normalizeSql(sql));
94
+ return { rows: [] };
95
+ },
96
+ } as unknown as PoolClient;
97
+
98
+ await createProjectRoomTables.down?.(client);
99
+
100
+ expect(statements).toEqual([
101
+ "DROP INDEX IF EXISTS idx_lattice_project_room_messages_room_created",
102
+ "DROP INDEX IF EXISTS uq_lattice_project_room_messages_idempotency",
103
+ "DROP TABLE IF EXISTS lattice_project_room_messages",
104
+ "DROP INDEX IF EXISTS idx_lattice_project_bot_memberships_room",
105
+ "DROP INDEX IF EXISTS idx_lattice_project_bot_memberships_project",
106
+ "DROP INDEX IF EXISTS uq_lattice_project_bot_memberships_mention",
107
+ "DROP INDEX IF EXISTS uq_lattice_project_bot_memberships_coordinator",
108
+ "DROP TABLE IF EXISTS lattice_project_bot_memberships",
109
+ "DROP INDEX IF EXISTS idx_lattice_project_memberships_project",
110
+ "DROP TABLE IF EXISTS lattice_project_memberships",
111
+ "DROP TABLE IF EXISTS lattice_project_rooms",
112
+ ]);
113
+ });
114
+ });
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import type { Pool } from "pg";
3
+
4
+ import { PostgreSQLProjectRoomStore } from "../stores/PostgreSQLProjectRoomStore";
5
+
6
+ const query = jest.fn();
7
+ const pool = { query } as unknown as Pool;
8
+ const now = new Date("2026-08-30T12:00:00.000Z");
9
+ const row = {
10
+ id: "room-1", tenant_id: "tenant-1", workspace_id: "workspace-1", project_id: "project-1",
11
+ type: "main", name: "Main", created_at: now, updated_at: now,
12
+ };
13
+
14
+ describe("PostgreSQLProjectRoomStore", () => {
15
+ beforeEach(() => query.mockReset());
16
+
17
+ it("atomically ensures the persisted winner without changing its semantic fields", async () => {
18
+ query.mockResolvedValueOnce({ rows: [row] });
19
+ const store = new PostgreSQLProjectRoomStore({ pool });
20
+
21
+ await expect(store.ensureMainRoom({
22
+ id: "candidate", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", name: "Candidate",
23
+ })).resolves.toEqual({
24
+ id: "room-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1",
25
+ type: "main", name: "Main", createdAt: now, updatedAt: now,
26
+ });
27
+
28
+ const [sql, params] = query.mock.calls[0] as [string, unknown[]];
29
+ expect(sql).toContain("ON CONFLICT (tenant_id, project_id, type)");
30
+ expect(sql).toContain("SET updated_at = lattice_project_rooms.updated_at");
31
+ expect(sql).toContain("RETURNING");
32
+ expect(params).toEqual(["candidate", "tenant-1", "workspace-1", "project-1", "main", "Candidate"]);
33
+ });
34
+
35
+ it("uses exact tenant filters when finding the main room", async () => {
36
+ query.mockResolvedValueOnce({ rows: [row] });
37
+ const store = new PostgreSQLProjectRoomStore({ pool });
38
+ await expect(store.getMainRoom("tenant-1", "project-1")).resolves.toMatchObject({ id: "room-1" });
39
+ expect(query.mock.calls[0][0]).toContain("WHERE tenant_id = $1 AND project_id = $2 AND type = 'main'");
40
+ expect(query.mock.calls[0][1]).toEqual(["tenant-1", "project-1"]);
41
+ });
42
+
43
+ it("rejects malformed rows", async () => {
44
+ query.mockResolvedValueOnce({ rows: [{ ...row, type: "side" }] });
45
+ const store = new PostgreSQLProjectRoomStore({ pool });
46
+ await expect(store.getMainRoom("tenant-1", "project-1")).rejects.toThrow("Invalid project room row");
47
+ });
48
+ });
@@ -87,4 +87,31 @@ describeIfDatabase("PostgreSQLTaskStore timestamp contract", () => {
87
87
  task.updatedAt,
88
88
  )).resolves.toBeNull();
89
89
  });
90
+
91
+ it("lists exact JSONB dependents and nonproject tasks in deterministic order", async () => {
92
+ const common = {
93
+ tenantId, ownerType: "agent", ownerId: "agent-1", workspaceId: "workspace-1", projectId: "project-1",
94
+ } as const;
95
+ for (const [id, status, dependencies] of [
96
+ ["dependent-a", "pending", ["dependency-1"]],
97
+ ["dependent-b", "interrupted", ["dependency-1", "dependency-1"]],
98
+ ["dependent-c", "pending", ["dependency-1"]],
99
+ ] as const) {
100
+ await store.create({ ...common, id, title: id, status, dependencies });
101
+ }
102
+ await pool.query("UPDATE lattice_tasks SET created_at = $1 WHERE tenant_id = $2", ["2026-01-01T00:00:00.000Z", tenantId]);
103
+
104
+ const dependents = await store.listDependents({
105
+ tenantId, workspaceId: "workspace-1", projectId: "project-1", dependencyTaskId: "dependency-1",
106
+ statuses: ["pending", "interrupted"], limit: 2, offset: 0,
107
+ });
108
+ expect(dependents.map(({ id }) => id)).toEqual(["dependent-c", "dependent-b"]);
109
+
110
+ for (const [id, projectId] of [["nonproject-d", undefined], ["nonproject-c", ""], ["nonproject-b", "default"]] as const) {
111
+ await store.create({ id, tenantId, ownerType: "user", ownerId: "user-1", title: id, projectId });
112
+ }
113
+ await pool.query("UPDATE lattice_tasks SET created_at = $1 WHERE tenant_id = $2 AND id LIKE 'nonproject-%'", ["2026-01-02T00:00:00.000Z", tenantId]);
114
+ const nonproject = await store.list({ tenantId, projectId: null, limit: 2, offset: 1 });
115
+ expect(nonproject.map(({ id }) => id)).toEqual(["nonproject-c", "nonproject-b"]);
116
+ });
90
117
  });
@@ -210,6 +210,44 @@ describe("PostgreSQLTaskStore.create", () => {
210
210
  )).resolves.toBeNull();
211
211
  });
212
212
 
213
+ it("uses exact owner and project snapshot predicates for update and delete", async () => {
214
+ const store = new PostgreSQLTaskStore({ pool });
215
+ mockQuery
216
+ .mockResolvedValueOnce({ rows: [fullRow("task-1")], rowCount: 1 })
217
+ .mockResolvedValueOnce({ rows: [], rowCount: 1 });
218
+ const snapshot = {
219
+ status: "pending" as const, updatedAt: "2026-01-01T00:00:00.000Z",
220
+ ownerType: "agent" as const, ownerId: "owner-1", workspaceId: "workspace-1", projectId: "project-1",
221
+ };
222
+
223
+ await store.updateIfSnapshot("t1", "task-1", { title: "Updated" }, snapshot);
224
+ await store.deleteIfSnapshot("t1", "task-1", snapshot);
225
+
226
+ for (const [sql, params] of mockQuery.mock.calls as unknown as Array<[string, unknown[]]>) {
227
+ expect(sql).toContain("status =");
228
+ expect(sql).toContain("updated_at");
229
+ expect(sql).toContain("owner_type =");
230
+ expect(sql).toContain("owner_id =");
231
+ expect(sql).toContain("workspace_id IS NOT DISTINCT FROM");
232
+ expect(sql).toContain("project_id IS NOT DISTINCT FROM");
233
+ expect(params).toEqual(expect.arrayContaining([
234
+ "pending", snapshot.updatedAt, "agent", "owner-1", "workspace-1", "project-1",
235
+ ]));
236
+ }
237
+ });
238
+
239
+ it("uses null-safe predicates for a nonproject snapshot", async () => {
240
+ const store = new PostgreSQLTaskStore({ pool });
241
+ mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 0 });
242
+ await store.updateIfSnapshot("t1", "task-1", { title: "Updated" }, {
243
+ status: "pending", updatedAt: "2026-01-01T00:00:00.000Z", ownerType: "user", ownerId: "user-1",
244
+ workspaceId: null, projectId: null,
245
+ });
246
+ const [sql, params] = mockQuery.mock.calls[0] as unknown as [string, unknown[]];
247
+ expect(sql).toContain("workspace_id IS NOT DISTINCT FROM");
248
+ expect(params).toEqual(expect.arrayContaining([null]));
249
+ });
250
+
213
251
  it("lets only one caller update the same snapshot", async () => {
214
252
  const store = new PostgreSQLTaskStore({ pool });
215
253
  mockQuery
@@ -284,4 +322,23 @@ describe("PostgreSQLTaskStore.create", () => {
284
322
  )).resolves.toBeNull();
285
323
  });
286
324
 
325
+ it("queries exact dependents with strict bounds and deterministic ordering", async () => {
326
+ const store = new PostgreSQLTaskStore({ pool });
327
+ mockQuery.mockResolvedValueOnce({ rows: [fullRow("dependent-c"), fullRow("dependent-b")] });
328
+ const query = { tenantId: "t1", workspaceId: "workspace-1", projectId: "project-1", dependencyTaskId: "dependency-1", statuses: ["pending", "interrupted"], limit: 2, offset: 0 } as const;
329
+
330
+ expect((await store.listDependents(query)).map(({ id }) => id)).toEqual(["dependent-c", "dependent-b"]);
331
+ expect(mockQuery).toHaveBeenCalledWith(expect.stringMatching(/dependencies @> \$4::jsonb[\s\S]+status = ANY\(\$5::text\[\]\)[\s\S]+ORDER BY created_at DESC, id DESC LIMIT \$6 OFFSET \$7/), ["t1", "workspace-1", "project-1", '["dependency-1"]', ["pending", "interrupted"], 2, 0]);
332
+ await expect(store.listDependents({ ...query, statuses: [] })).rejects.toThrow(RangeError);
333
+ await expect(store.listDependents({ ...query, statuses: ["unknown"] as never })).rejects.toThrow(RangeError);
334
+ await expect(store.listDependents({ ...query, limit: 101 })).rejects.toThrow(RangeError);
335
+ });
336
+
337
+ it("uses null-project semantics before deterministic task pagination", async () => {
338
+ const store = new PostgreSQLTaskStore({ pool });
339
+ mockQuery.mockResolvedValueOnce({ rows: [] });
340
+ await store.list({ tenantId: "t1", projectId: null, limit: 2, offset: 1 });
341
+ expect(mockQuery).toHaveBeenCalledWith(expect.stringMatching(/\(project_id IS NULL OR project_id = '' OR project_id = 'default'\)[\s\S]+ORDER BY created_at DESC, id DESC/), ["t1", 2, 1]);
342
+ });
343
+
287
344
  });
@@ -1,4 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Pool } from "pg";
3
+ import { PostgreSQLTaskWorkItemStore } from "../stores/PostgreSQLTaskWorkItemStore";
2
4
 
3
5
  const databaseUrl = process.env.TEST_DATABASE_URL;
4
6
  const describeIfDatabase = databaseUrl ? describe : describe.skip;
@@ -31,3 +33,75 @@ describeIfDatabase("PostgreSQLTaskWorkItemStore pending key grammar", () => {
31
33
  ]);
32
34
  });
33
35
  });
36
+
37
+ describeIfDatabase("PostgreSQLTaskWorkItemStore lifecycle pagination", () => {
38
+ let adminPool: Pool;
39
+ let pool: Pool;
40
+ let store: PostgreSQLTaskWorkItemStore;
41
+ const tenantId = `work-item-pagination-${randomUUID()}`;
42
+ const schema = `work_item_${randomUUID().replace(/-/g, "")}`;
43
+
44
+ beforeAll(async () => {
45
+ adminPool = new Pool({ connectionString: databaseUrl });
46
+ await adminPool.query(`CREATE SCHEMA ${schema}`);
47
+ const poolConfig = { connectionString: databaseUrl, options: `-c search_path=${schema}` };
48
+ pool = new Pool(poolConfig);
49
+ store = new PostgreSQLTaskWorkItemStore({ poolConfig, autoMigrate: false });
50
+ await store.initialize();
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await pool.query("DELETE FROM lattice_task_work_items WHERE tenant_id = $1 OR tenant_id = $2", [tenantId, `${tenantId}-other`]);
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await store.dispose();
59
+ await pool.end();
60
+ await adminPool.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
61
+ await adminPool.end();
62
+ });
63
+
64
+ it("pages same-millisecond events with exact scope and action filters", async () => {
65
+ const common = { tenantId, actor: "agent:a", workspaceId: "workspace-1", projectId: "project-1" } as const;
66
+ for (const [taskId, action, eventKey] of [
67
+ ["task-a", "in_progress", "event:a"],
68
+ ["task-b", "completed", "event:b"],
69
+ ["task-c", "in_progress", "event:c"],
70
+ ] as const) await store.createIfAbsentByEventKey({ ...common, taskId, action, eventKey });
71
+ await store.createIfAbsentByEventKey({ ...common, taskId: "wrong-action", action: "activity", eventKey: "event:action" });
72
+ await store.createIfAbsentByEventKey({ ...common, taskId: "wrong-project", action: "completed", eventKey: "event:project", projectId: "project-2" });
73
+ await store.createIfAbsentByEventKey({ ...common, taskId: "wrong-workspace", action: "completed", eventKey: "event:workspace", workspaceId: "workspace-2" });
74
+ await store.createIfAbsentByEventKey({ ...common, taskId: "wrong-tenant", action: "completed", eventKey: "event:tenant", tenantId: `${tenantId}-other` });
75
+ await pool.query(
76
+ `UPDATE lattice_task_work_items
77
+ SET id = CASE task_id WHEN 'task-a' THEN 'event-a' WHEN 'task-b' THEN 'event-b' ELSE 'event-c' END,
78
+ created_at = '2026-01-02T00:00:00.123Z'
79
+ WHERE tenant_id = $1 AND task_id IN ('task-a', 'task-b', 'task-c')`,
80
+ [tenantId],
81
+ );
82
+ const query = { tenantId, workspaceId: "workspace-1", projectId: "project-1", actions: ["in_progress", "completed"], limit: 2 } as const;
83
+
84
+ const page1 = await store.listProjectLifecycleEvents(query);
85
+ const page2 = await store.listProjectLifecycleEvents({ ...query, before: { createdAt: page1[1].createdAt, id: page1[1].id } });
86
+
87
+ expect(page1.map(({ id }) => id)).toEqual(["event-c", "event-b"]);
88
+ expect(page2.map(({ id }) => id)).toEqual(["event-a"]);
89
+ expect(page1.every(({ createdAt }) => createdAt.toISOString().endsWith(".123Z"))).toBe(true);
90
+ });
91
+
92
+ it("serves immediate pending evidence and Project lifecycle history from one migrated table", async () => {
93
+ const common = { tenantId, taskId: "combined-task", workspaceId: "workspace-1", projectId: "project-1" } as const;
94
+ await store.createIfAbsentByEventKey({
95
+ ...common, eventKey: "execution-result:ordinary-thread", action: "execution_result", actor: "agent:worker",
96
+ });
97
+ await store.createIfAbsentByEventKey({
98
+ ...common, eventKey: "project-completed:combined-task", action: "completed", actor: "agent:project-worker",
99
+ });
100
+
101
+ await expect(store.listPendingExecutionResults({ tenantId, taskId: common.taskId, limit: 10 }))
102
+ .resolves.toEqual([expect.objectContaining({ eventKey: "execution-result:ordinary-thread" })]);
103
+ await expect(store.listProjectLifecycleEvents({
104
+ tenantId, workspaceId: common.workspaceId, projectId: common.projectId, actions: ["completed"], limit: 10,
105
+ })).resolves.toEqual([expect.objectContaining({ eventKey: "project-completed:combined-task" })]);
106
+ });
107
+ });
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, it } from "@jest/globals";
2
+
3
+ const recordedMigrationNames: string[] = [];
4
+ const clientSql: string[] = [];
5
+
6
+ jest.mock("pg", () => {
7
+ const client = {
8
+ query: async (sql: string, params?: unknown[]) => {
9
+ clientSql.push(sql);
10
+ if (sql.includes("information_schema.tables")) return { rows: [{ exists: true }] };
11
+ if (sql.includes("pg_index")) return { rows: [] };
12
+ if (sql.includes("INSERT INTO lattice_schema_migrations")) {
13
+ recordedMigrationNames.push(params?.[0] as string);
14
+ }
15
+ return { rows: [] };
16
+ },
17
+ release: () => undefined,
18
+ };
19
+ const pool = {
20
+ connect: async () => client,
21
+ query: async () => ({ rows: [] }),
22
+ end: async () => undefined,
23
+ };
24
+ return { Pool: function Pool() { return pool; } };
25
+ });
26
+
27
+ import { PostgreSQLTaskWorkItemStore } from "../stores/PostgreSQLTaskWorkItemStore";
28
+
29
+ describe("PostgreSQLTaskWorkItemStore standalone migrations", () => {
30
+ beforeEach(() => {
31
+ recordedMigrationNames.length = 0;
32
+ clientSql.length = 0;
33
+ });
34
+
35
+ it("applies the complete work-item migration chain", async () => {
36
+ const store = new PostgreSQLTaskWorkItemStore({
37
+ poolConfig: "postgres://unused",
38
+ autoMigrate: false,
39
+ });
40
+
41
+ await store.initialize();
42
+
43
+ expect(recordedMigrationNames).toEqual([
44
+ "create_task_work_items_table",
45
+ "add_task_work_item_project_fields",
46
+ "add_task_work_item_event_key",
47
+ "add_task_work_item_pending_indexes",
48
+ "add_project_lifecycle_event_index",
49
+ ]);
50
+ expect(clientSql.find((sql) => sql.includes("information_schema.tables")))
51
+ .toContain("table_schema = current_schema()");
52
+ expect(clientSql.some((sql) => sql.includes("CREATE TABLE IF NOT EXISTS lattice_task_work_items"))).toBe(true);
53
+ expect(clientSql.some((sql) => sql.includes(
54
+ "created_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', clock_timestamp())",
55
+ ))).toBe(true);
56
+ expect(clientSql.some((sql) => sql.includes(
57
+ "SET created_at = date_trunc('milliseconds', created_at)",
58
+ ))).toBe(true);
59
+ expect(clientSql.some((sql) => sql.includes("idx_task_work_items_project_lifecycle"))).toBe(true);
60
+ await store.dispose();
61
+ });
62
+ });
@@ -1,6 +1,7 @@
1
1
  import type { Pool } from "pg";
2
2
  import { PostgreSQLTaskWorkItemStore } from "../stores/PostgreSQLTaskWorkItemStore";
3
3
  import {
4
+ addProjectLifecycleEventIndex,
4
5
  addTaskWorkItemEventKeyMigration,
5
6
  addTaskWorkItemPendingIndexesMigration,
6
7
  } from "../migrations/task_work_items_migration";
@@ -13,6 +14,10 @@ describe("PostgreSQLTaskWorkItemStore", () => {
13
14
  created_at: "2026-01-01T00:00:00.000Z",
14
15
  };
15
16
 
17
+ it("requires a pool or connection configuration", () => {
18
+ expect(() => new PostgreSQLTaskWorkItemStore({})).toThrow("Either pool or poolConfig must be provided");
19
+ });
20
+
16
21
  it.each([
17
22
  [undefined, "ASC"],
18
23
  ["asc", "ASC"],
@@ -77,6 +82,25 @@ describe("PostgreSQLTaskWorkItemStore", () => {
77
82
  expect(result[0].eventKey).toBeUndefined();
78
83
  });
79
84
 
85
+ it.each([null, [], "legacy", 1] as const)("maps non-object detail %p to undefined", async (detail) => {
86
+ const query = jest.fn().mockResolvedValue({ rows: [{ ...row, detail }] });
87
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
88
+
89
+ const result = await store.list({ tenantId: "tenant-1", taskId: "task-1" });
90
+
91
+ expect(result[0].detail).toBeUndefined();
92
+ });
93
+
94
+ it("preserves plain-object detail", async () => {
95
+ const detail = { markdown: "status" };
96
+ const query = jest.fn().mockResolvedValue({ rows: [{ ...row, detail }] });
97
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
98
+
99
+ const result = await store.list({ tenantId: "tenant-1", taskId: "task-1" });
100
+
101
+ expect(result[0].detail).toEqual(detail);
102
+ });
103
+
80
104
  it("atomically inserts by task-scoped event key or returns the existing row", async () => {
81
105
  const query = jest.fn().mockResolvedValue({ rows: [row] });
82
106
  const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
@@ -92,6 +116,49 @@ describe("PostgreSQLTaskWorkItemStore", () => {
92
116
  expect.arrayContaining(["tenant-1", "task-1", "evidence:key"]),
93
117
  );
94
118
  expect(result.eventKey).toBe("evidence:key");
119
+ expect(query).toHaveBeenCalledWith(
120
+ expect.stringMatching(/created_at[\s\S]+date_trunc\('milliseconds', clock_timestamp\(\)\)/),
121
+ expect.any(Array),
122
+ );
123
+ });
124
+
125
+ it("creates ordinary work items with an explicit millisecond timestamp", async () => {
126
+ const query = jest.fn().mockResolvedValue({ rows: [row] });
127
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
128
+
129
+ await store.create({ tenantId: "tenant-1", taskId: "task-1", action: "activity", actor: "agent:a" });
130
+
131
+ expect(query).toHaveBeenCalledWith(
132
+ expect.stringMatching(/created_at[\s\S]+date_trunc\('milliseconds', clock_timestamp\(\)\)/),
133
+ expect.any(Array),
134
+ );
135
+ });
136
+
137
+ it("atomically inserts activity from an exact task snapshot", async () => {
138
+ const query = jest.fn().mockResolvedValue({ rows: [row] });
139
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
140
+ const snapshot = {
141
+ status: "in_progress" as const, updatedAt: "2026-01-01T00:00:00.000Z", ownerType: "agent" as const,
142
+ ownerId: "agent-1", workspaceId: "workspace-1", projectId: "project-1",
143
+ };
144
+ await store.createIfTaskSnapshot({
145
+ tenantId: "tenant-1", taskId: "task-1", action: "activity", actor: "agent:agent-1",
146
+ workspaceId: "workspace-1", projectId: "project-1",
147
+ }, snapshot);
148
+ expect(query).toHaveBeenCalledWith(
149
+ expect.stringMatching(/INSERT INTO lattice_task_work_items[\s\S]+SELECT[\s\S]+FROM lattice_tasks[\s\S]+status = [\s\S]+updated_at[\s\S]+owner_type = [\s\S]+owner_id = [\s\S]+workspace_id IS NOT DISTINCT FROM[\s\S]+project_id IS NOT DISTINCT FROM[\s\S]+RETURNING/),
150
+ expect.arrayContaining(["tenant-1", "task-1", "in_progress", snapshot.updatedAt, "agent", "agent-1", "workspace-1", "project-1"]),
151
+ );
152
+ });
153
+
154
+ it("uses null-safe scope predicates for nonproject activity", async () => {
155
+ const query = jest.fn().mockResolvedValue({ rows: [] });
156
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
157
+ await store.createIfTaskSnapshot({ tenantId: "tenant-1", taskId: "task-1", action: "activity", actor: "user:user-1" }, {
158
+ status: "pending", updatedAt: "2026-01-01T00:00:00.000Z", ownerType: "user", ownerId: "user-1",
159
+ workspaceId: null, projectId: null,
160
+ });
161
+ expect(query).toHaveBeenCalledWith(expect.stringContaining("workspace_id IS NOT DISTINCT FROM"), expect.arrayContaining([null]));
95
162
  });
96
163
 
97
164
  it("uses one bounded deterministic anti-join with C-collated ASCII keys that reject non-ASCII", async () => {
@@ -166,4 +233,33 @@ describe("PostgreSQLTaskWorkItemStore", () => {
166
233
  /\(tenant_id, task_id, \(detail ->> 'executionResultId'\)\)[\s\S]+WHERE action = 'execution_reconciled'/,
167
234
  ));
168
235
  });
236
+
237
+ it("queries project lifecycle events with strict bounds and exclusive cursor", async () => {
238
+ const query = jest.fn().mockResolvedValue({ rows: [{ ...row, id: "event-c", action: "completed", workspace_id: "workspace-1", project_id: "project-1" }] });
239
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
240
+ const before = { createdAt: new Date("2026-01-02T00:00:00.000Z"), id: "event-b" };
241
+ const request = { tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", actions: ["in_progress", "completed"], before, limit: 2 } as const;
242
+
243
+ await expect(store.listProjectLifecycleEvents(request)).resolves.toHaveLength(1);
244
+ expect(query).toHaveBeenCalledWith(expect.stringMatching(/action = ANY\(\$4::text\[\]\)[\s\S]+event_key IS NOT NULL AND event_key <> ''[\s\S]+created_at < \$5::timestamptz[\s\S]+id < \$6[\s\S]+ORDER BY created_at DESC, id DESC LIMIT \$7/), ["tenant-1", "workspace-1", "project-1", ["in_progress", "completed"], before.createdAt, "event-b", 2]);
245
+ await expect(store.listProjectLifecycleEvents({ ...request, actions: [] })).rejects.toThrow(RangeError);
246
+ await expect(store.listProjectLifecycleEvents({ ...request, actions: ["unknown"] as never })).rejects.toThrow(RangeError);
247
+ await expect(store.listProjectLifecycleEvents({ ...request, limit: 101 })).rejects.toThrow(RangeError);
248
+ });
249
+
250
+ it("rejects malformed lifecycle event outer rows", async () => {
251
+ const query = jest.fn().mockResolvedValue({ rows: [{ ...row, id: "", workspace_id: "workspace-1", project_id: "project-1" }] });
252
+ const store = new PostgreSQLTaskWorkItemStore({ query } as unknown as Pool);
253
+ await expect(store.listProjectLifecycleEvents({ tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", actions: ["completed"], limit: 10 }))
254
+ .rejects.toThrow("Invalid project lifecycle event row");
255
+ });
256
+
257
+ it("creates the project lifecycle event index", async () => {
258
+ const query = jest.fn().mockResolvedValue({ rows: [] });
259
+ await addProjectLifecycleEventIndex.up({ query } as never);
260
+ expect(query.mock.calls[0][0]).toMatch(
261
+ /UPDATE lattice_task_work_items\s+SET created_at = date_trunc\('milliseconds', created_at\)/,
262
+ );
263
+ expect(query).toHaveBeenCalledWith(expect.stringMatching(/created_at DESC,\s*id DESC[\s\S]+WHERE event_key IS NOT NULL/));
264
+ });
169
265
  });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, jest } from "@jest/globals";
2
+ import type { PoolClient } from "pg";
3
+
4
+ const registeredMigrationNames: string[] = [];
5
+
6
+ jest.mock("@axiom-lattice/core", () => ({}));
7
+ jest.mock("pg", () => ({ Pool: class Pool {} }));
8
+ jest.mock("../migrations/migration", () => ({
9
+ MigrationManager: class MigrationManager {
10
+ register(migration: { name: string }): void {
11
+ registeredMigrationNames.push(migration.name);
12
+ }
13
+ async migrate(): Promise<void> {
14
+ throw new Error("stop after migrations");
15
+ }
16
+ },
17
+ }));
18
+
19
+ import { createPgStoreConfig } from "../createPgStoreConfig";
20
+ import { addTrustedRunContextColumn } from "../migrations/add_trusted_run_context_column";
21
+ import { ThreadMessageQueueStore } from "../stores/ThreadMessageQueueStore";
22
+
23
+ function normalizeSql(sql: string): string {
24
+ return sql.replace(/\s+/g, " ").trim();
25
+ }
26
+
27
+ describe("thread queue trusted context migration", () => {
28
+ it("uses the reserved identity and is centrally registered exactly once", async () => {
29
+ expect(addTrustedRunContextColumn.version).toBe(173);
30
+ expect(addTrustedRunContextColumn.name).toBe("add_thread_queue_trusted_run_context");
31
+ registeredMigrationNames.length = 0;
32
+
33
+ await expect(createPgStoreConfig("postgres://unused")).rejects.toThrow("stop after migrations");
34
+
35
+ expect(registeredMigrationNames.filter((name) => name === addTrustedRunContextColumn.name)).toHaveLength(1);
36
+ });
37
+
38
+ it("registers the migration in the standalone queue store", () => {
39
+ registeredMigrationNames.length = 0;
40
+
41
+ new ThreadMessageQueueStore({ poolConfig: { connectionString: "postgres://unused" }, autoMigrate: false });
42
+
43
+ expect(registeredMigrationNames.filter((name) => name === addTrustedRunContextColumn.name)).toHaveLength(1);
44
+ });
45
+
46
+ it("adds and removes both durable columns", async () => {
47
+ const statements: string[] = [];
48
+ const client = { query: async (sql: string) => {
49
+ statements.push(normalizeSql(sql));
50
+ return { rows: [] };
51
+ } } as unknown as PoolClient;
52
+
53
+ await addTrustedRunContextColumn.up(client);
54
+ expect(statements.join(" ")).toContain("ADD COLUMN IF NOT EXISTS trusted_run_context JSONB");
55
+ expect(statements.join(" ")).toContain("ADD COLUMN IF NOT EXISTS execution_mode VARCHAR(20)");
56
+ expect(statements.join(" ")).toContain("execution_mode IS NULL OR execution_mode = 'followup'");
57
+
58
+ statements.length = 0;
59
+ await addTrustedRunContextColumn.down?.(client);
60
+ expect(statements.join(" ")).toContain("DROP COLUMN IF EXISTS execution_mode");
61
+ expect(statements.join(" ")).toContain("DROP COLUMN IF EXISTS trusted_run_context");
62
+ });
63
+ });