@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/pg-stores",
3
- "version": "3.1.1",
3
+ "version": "3.1.2",
4
4
  "description": "PG stores implementation for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,8 +25,8 @@
25
25
  "@langchain/core": "1.1.30",
26
26
  "pg": "^8.16.3",
27
27
  "uuid": "^9.0.1",
28
- "@axiom-lattice/core": "4.2.1",
29
- "@axiom-lattice/protocols": "4.1.1"
28
+ "@axiom-lattice/core": "4.2.2",
29
+ "@axiom-lattice/protocols": "4.1.2"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -0,0 +1,122 @@
1
+ import type { Pool } from "pg";
2
+ import { DuplicateChannelBindingSubjectError } from "@axiom-lattice/protocols";
3
+ import { ChannelBindingStore } from "../stores/ChannelBindingStore";
4
+
5
+ const input = { channel: "room", channelInstallationId: "install-1", tenantId: "tenant-a",
6
+ senderId: "sender-1", agentId: "agent-1", threadId: "thread-1" };
7
+
8
+ describe("ChannelBindingStore protected bindings", () => {
9
+ it("uses tenant-safe exact queries and excludes channels in SQL before pagination", async () => {
10
+ const query = jest.fn().mockResolvedValue({ rows: [] });
11
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
12
+ await store.findById("tenant-a", "binding-1");
13
+ await store.findBySubject(input);
14
+ await store.list({ tenantId: "tenant-a", excludeChannels: ["room"], limit: 50 });
15
+
16
+ expect(query.mock.calls[0][0]).toMatch(/id = \$1 AND tenant_id = \$2/);
17
+ expect(query.mock.calls[1][0]).not.toMatch(/enabled/);
18
+ expect(query.mock.calls[2][0]).toMatch(/channel <> ALL/);
19
+ expect(query.mock.calls[2][0].indexOf("channel <> ALL")).toBeLessThan(query.mock.calls[2][0].indexOf("LIMIT"));
20
+ });
21
+
22
+ it("parameterizes escaped installation-prefix exclusions before pagination", async () => {
23
+ const query = jest.fn().mockResolvedValue({ rows: [] });
24
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
25
+ await store.list({
26
+ tenantId: "tenant-a",
27
+ excludeInstallationIdPrefixes: ["room-internal:", "literal%_\\"],
28
+ limit: 50,
29
+ });
30
+ const [sql, values] = query.mock.calls[0];
31
+ expect(sql).toMatch(/channel_installation_id NOT LIKE \$\d+ ESCAPE '\\'/);
32
+ expect(sql.indexOf("NOT LIKE")).toBeLessThan(sql.indexOf("LIMIT"));
33
+ expect(values).toEqual(["tenant-a", "room-internal:%", "literal\\%\\_\\\\%", 50, 0]);
34
+ });
35
+
36
+ it.each(["room", "lark"])(
37
+ "persists thread id and maps one of two concurrent %s SQLSTATE 23505 creates",
38
+ async (channel) => {
39
+ const duplicate = Object.assign(new Error("duplicate"), {
40
+ code: "23505",
41
+ constraint: "lattice_channel_bindings_channel_channel_installation_id_te_key",
42
+ });
43
+ const row = {
44
+ id: "binding-1", channel: "room", channel_installation_id: "install-1",
45
+ tenant_id: "tenant-a", sender_id: "sender-1", agent_id: "agent-1",
46
+ thread_id: "thread-1", workspace_id: null, project_id: null, thread_mode: "fixed",
47
+ sender_display_name: null, sender_metadata: null, enabled: true,
48
+ created_at: new Date(), updated_at: new Date(),
49
+ };
50
+ const query = jest.fn()
51
+ .mockResolvedValueOnce({ rows: [row] })
52
+ .mockRejectedValueOnce(duplicate);
53
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
54
+ const subject = { ...input, channel };
55
+ const results = await Promise.allSettled([store.create(subject), store.create(subject)]);
56
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
57
+ expect((results.find((result) => result.status === "rejected") as PromiseRejectedResult).reason)
58
+ .toBeInstanceOf(DuplicateChannelBindingSubjectError);
59
+ expect(query.mock.calls[0][0]).toMatch(/thread_id/);
60
+ expect(query.mock.calls[0][1]).toContain("thread-1");
61
+ },
62
+ );
63
+
64
+ it("rethrows unrelated PostgreSQL unique violations", async () => {
65
+ const unrelated = Object.assign(new Error("duplicate id"), {
66
+ code: "23505", constraint: "lattice_channel_bindings_pkey",
67
+ });
68
+ const store = new ChannelBindingStore({
69
+ pool: { query: jest.fn().mockRejectedValue(unrelated) } as unknown as Pool,
70
+ });
71
+ await expect(store.create(input)).rejects.toBe(unrelated);
72
+ });
73
+
74
+ it("passes the requested initial enabled state to PostgreSQL", async () => {
75
+ const query = jest.fn().mockResolvedValue({ rows: [{ enabled: false }] });
76
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
77
+ await store.create({ ...input, enabled: false });
78
+ expect(query.mock.calls[0][0]).toMatch(/enabled/);
79
+ expect(query.mock.calls[0][1]).toContain(false);
80
+ });
81
+
82
+ it("uses tenant predicates for update and delete", async () => {
83
+ const row = {
84
+ id: "binding-1", channel: "lark", channel_installation_id: "install-1",
85
+ tenant_id: "tenant-a", sender_id: "sender-1", agent_id: "agent-2", thread_id: null,
86
+ workspace_id: null, project_id: null, thread_mode: "fixed", sender_display_name: null,
87
+ sender_metadata: null, enabled: true, created_at: new Date(), updated_at: new Date(),
88
+ };
89
+ const query = jest.fn().mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({ rows: [] });
90
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
91
+ await store.update("tenant-a", "binding-1", { agentId: "agent-2" });
92
+ await store.delete("tenant-a", "binding-1");
93
+ expect(query.mock.calls[0][0]).toMatch(/WHERE id = \$\d+ AND tenant_id = \$\d+/);
94
+ expect(query.mock.calls[1]).toEqual([
95
+ expect.stringMatching(/WHERE id = \$1 AND tenant_id = \$2/), ["binding-1", "tenant-a"],
96
+ ]);
97
+ });
98
+
99
+ it("rejects room imports before issuing SQL", async () => {
100
+ const query = jest.fn();
101
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
102
+ await expect(store.import("tenant-a", [{ ...input, channel: "lark" }, input])).rejects.toThrow(/room/i);
103
+ expect(query).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("rejects internal installation imports before issuing SQL", async () => {
107
+ const query = jest.fn();
108
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
109
+ await expect(store.import("tenant-a", [{
110
+ ...input, channel: "lark", channelInstallationId: "room-internal:project-1",
111
+ }])).rejects.toThrow(/internal/i);
112
+ expect(query).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("rejects cross-tenant imports before issuing SQL", async () => {
116
+ const query = jest.fn();
117
+ const store = new ChannelBindingStore({ pool: { query } as unknown as Pool });
118
+ await expect(store.import("tenant-a", [{ ...input, channel: "lark", tenantId: "tenant-b" }]))
119
+ .rejects.toThrow(/tenant mismatch/i);
120
+ expect(query).not.toHaveBeenCalled();
121
+ });
122
+ });
@@ -19,6 +19,22 @@ describe("PostgreSQLChannelInstallationStore", () => {
19
19
  jest.clearAllMocks();
20
20
  });
21
21
 
22
+ it("persists room installations with a plain record config", async () => {
23
+ const store = new PostgreSQLChannelInstallationStore({
24
+ pool: { query: mockQuery } as unknown as Pool,
25
+ });
26
+ mockQuery.mockResolvedValueOnce({ rows: [] });
27
+
28
+ await expect(store.createInstallation("tenant-a", "room-install", {
29
+ channel: "room",
30
+ config: {},
31
+ })).resolves.toMatchObject({
32
+ channel: "room",
33
+ config: {},
34
+ });
35
+ expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("INSERT INTO"), expect.arrayContaining(["room"]));
36
+ });
37
+
22
38
  it("retrieves and decrypts an installation by id", async () => {
23
39
  const store = new PostgreSQLChannelInstallationStore({
24
40
  pool: { query: mockQuery } as unknown as Pool,
@@ -0,0 +1,117 @@
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import type { Pool } from "pg";
3
+
4
+ import {
5
+ PostgreSQLProjectBotMembershipStore,
6
+ ProjectBotMembershipIdConflictError,
7
+ } from "../stores/PostgreSQLProjectBotMembershipStore";
8
+
9
+ const query = jest.fn();
10
+ const pool = { query } as unknown as Pool;
11
+ const now = new Date("2026-08-30T12:00:00.000Z");
12
+ const row = {
13
+ id: "bot-1", tenant_id: "tenant-1", workspace_id: "workspace-1", project_id: "project-1",
14
+ room_id: "room-1", assistant_id: "assistant-1", role: "coordinator", title: "Lead",
15
+ responsibility: "Coordinate", mention_name: "lead", status: "active", room_thread_id: "thread-1",
16
+ joined_at: now, updated_at: now,
17
+ };
18
+
19
+ function transactionPool(clientQuery: jest.Mock): Pool {
20
+ return { connect: jest.fn().mockResolvedValue({ query: clientQuery, release: jest.fn() }) } as unknown as Pool;
21
+ }
22
+
23
+ describe("PostgreSQLProjectBotMembershipStore", () => {
24
+ beforeEach(() => query.mockReset());
25
+
26
+ it("lists by joined time and finds with exact tenant scopes", async () => {
27
+ query.mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({ rows: [row] });
28
+ const store = new PostgreSQLProjectBotMembershipStore({ pool });
29
+ await expect(store.list("tenant-1", "project-1")).resolves.toHaveLength(1);
30
+ await expect(store.findById("tenant-1", "bot-1")).resolves.toMatchObject({ id: "bot-1" });
31
+ await expect(store.findByAssistant("tenant-1", "project-1", "assistant-1")).resolves.toMatchObject({ assistantId: "assistant-1" });
32
+ expect(query.mock.calls[0][0]).toContain("ORDER BY joined_at ASC, id ASC");
33
+ expect(query.mock.calls[1][1]).toEqual(["tenant-1", "bot-1"]);
34
+ expect(query.mock.calls[2][1]).toEqual(["tenant-1", "project-1", "assistant-1"]);
35
+ });
36
+
37
+ it("reactivates the durable assistant membership without replacing immutable fields", async () => {
38
+ const updated = { ...row, role: "specialist", title: "Builder", responsibility: null, mention_name: "builder", updated_at: new Date(now.getTime() + 1) };
39
+ const clientQuery = jest.fn()
40
+ .mockResolvedValueOnce({}).mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [row] })
41
+ .mockResolvedValueOnce({ rows: [updated] }).mockResolvedValueOnce({});
42
+ const store = new PostgreSQLProjectBotMembershipStore({ pool: transactionPool(clientQuery) });
43
+ await expect(store.save({
44
+ id: "candidate", tenantId: "tenant-1", workspaceId: "other", projectId: "project-1", roomId: "other-room",
45
+ assistantId: "assistant-1", role: "specialist", title: "Builder", mentionName: "builder", status: "removed", roomThreadId: "other-thread",
46
+ })).resolves.toEqual({ kind: "updated", membership: expect.objectContaining({ id: "bot-1", roomThreadId: "thread-1", status: "active" }) });
47
+ expect(clientQuery.mock.calls.map((call) => String(call[0]))).toEqual([
48
+ "BEGIN", expect.stringContaining("pg_advisory_xact_lock"), expect.stringContaining("FOR UPDATE"),
49
+ expect.stringContaining("UPDATE lattice_project_bot_memberships"), "COMMIT",
50
+ ]);
51
+ expect(clientQuery.mock.calls[3][1]).toEqual(["tenant-1", "bot-1", "specialist", "Builder", null, "builder"]);
52
+ });
53
+
54
+ it("inserts a new active membership and maps candidate ID collisions", async () => {
55
+ const inserted = { ...row, role: "specialist" };
56
+ const clientQuery = jest.fn()
57
+ .mockResolvedValueOnce({}).mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [] })
58
+ .mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [inserted] }).mockResolvedValueOnce({});
59
+ const store = new PostgreSQLProjectBotMembershipStore({ pool: transactionPool(clientQuery) });
60
+ await expect(store.save({
61
+ id: "bot-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
62
+ assistantId: "assistant-1", role: "specialist", title: "Lead", responsibility: "Coordinate", mentionName: "lead", status: "paused", roomThreadId: "thread-1",
63
+ })).resolves.toEqual({ kind: "created", membership: expect.objectContaining({ status: "active" }) });
64
+ expect(clientQuery.mock.calls[4][0]).toContain("date_trunc('milliseconds', clock_timestamp())");
65
+ expect(clientQuery.mock.calls[4][1]).toEqual(["bot-1", "tenant-1", "workspace-1", "project-1", "room-1", "assistant-1", "specialist", "Lead", "Coordinate", "lead", "thread-1"]);
66
+
67
+ const collisionQuery = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [{ id: "bot-1" }] });
68
+ const collisionStore = new PostgreSQLProjectBotMembershipStore({ pool: transactionPool(collisionQuery) });
69
+ await expect(collisionStore.save({
70
+ id: "bot-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
71
+ assistantId: "assistant-2", role: "specialist", title: "Bot", mentionName: "bot", status: "active", roomThreadId: "thread-2",
72
+ })).rejects.toBeInstanceOf(ProjectBotMembershipIdConflictError);
73
+ });
74
+
75
+ it.each([
76
+ ["uq_lattice_project_bot_memberships_coordinator", "coordinator_conflict"],
77
+ ["uq_lattice_project_bot_memberships_mention", "mention_conflict"],
78
+ ] as const)("maps known save constraint %s without leaking SQL errors", async (constraint, kind) => {
79
+ const clientQuery = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [] })
80
+ .mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(Object.assign(new Error("raw sql"), { code: "23505", constraint }));
81
+ const store = new PostgreSQLProjectBotMembershipStore({ pool: transactionPool(clientQuery) });
82
+ await expect(store.save({
83
+ id: "bot-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
84
+ assistantId: "assistant-1", role: "coordinator", title: "Lead", mentionName: "lead", status: "active", roomThreadId: "thread-1",
85
+ })).resolves.toEqual({ kind });
86
+ });
87
+
88
+ it("updates mutable fields with exact monotonic millisecond CAS", async () => {
89
+ const updated = { ...row, status: "removed", updated_at: new Date(now.getTime() + 1) };
90
+ const clientQuery = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [{ project_id: "project-1" }] })
91
+ .mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({ rows: [updated] }).mockResolvedValueOnce({});
92
+ const store = new PostgreSQLProjectBotMembershipStore({ pool: transactionPool(clientQuery) });
93
+ await expect(store.update({ tenantId: "tenant-1", id: "bot-1", patch: { status: "removed" }, expectedUpdatedAt: now }))
94
+ .resolves.toEqual({ kind: "updated", membership: expect.objectContaining({ status: "removed" }) });
95
+ const updateCall = clientQuery.mock.calls[4] as [string, unknown[]];
96
+ expect(updateCall[0]).toContain("GREATEST(date_trunc('milliseconds', updated_at) + interval '1 millisecond'");
97
+ expect(updateCall[0]).toContain("date_trunc('milliseconds', updated_at) = $3");
98
+ expect(updateCall[1]).toEqual(["tenant-1", "bot-1", now, "coordinator", "Lead", "Coordinate", "lead", "removed"]);
99
+ });
100
+
101
+ it("returns not_found and CAS conflict without writing", async () => {
102
+ const absent = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({});
103
+ await expect(new PostgreSQLProjectBotMembershipStore({ pool: transactionPool(absent) }).update({ tenantId: "tenant-1", id: "missing", patch: {}, expectedUpdatedAt: now }))
104
+ .resolves.toEqual({ kind: "not_found" });
105
+ const stale = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [{ project_id: "project-1" }] })
106
+ .mockResolvedValueOnce({}).mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({});
107
+ await expect(new PostgreSQLProjectBotMembershipStore({ pool: transactionPool(stale) }).update({ tenantId: "tenant-1", id: "bot-1", patch: {}, expectedUpdatedAt: new Date(0) }))
108
+ .resolves.toEqual({ kind: "conflict" });
109
+ expect(stale.mock.calls.some((call) => String(call[0]).startsWith("UPDATE"))).toBe(false);
110
+ });
111
+
112
+ it("strictly rejects malformed persisted rows", async () => {
113
+ query.mockResolvedValueOnce({ rows: [{ ...row, status: "invalid" }] });
114
+ await expect(new PostgreSQLProjectBotMembershipStore({ pool }).list("tenant-1", "project-1"))
115
+ .rejects.toThrow("Invalid project bot membership row");
116
+ });
117
+ });
@@ -0,0 +1,235 @@
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import type { Pool } from "pg";
3
+
4
+ import {
5
+ DuplicateProjectMembershipError,
6
+ ProjectMembershipIdConflictError,
7
+ PostgreSQLProjectMembershipStore,
8
+ } from "../stores/PostgreSQLProjectMembershipStore";
9
+
10
+ const query = jest.fn();
11
+ const pool = { query } as unknown as Pool;
12
+ const now = new Date("2026-08-30T12:00:00.000Z");
13
+ const membership = {
14
+ id: "member-1", tenant_id: "tenant-1", project_id: "project-1", user_id: "user-1",
15
+ role: "owner", status: "active", joined_at: now, updated_at: now,
16
+ };
17
+
18
+ function transactionPool(clientQuery: jest.Mock): Pool {
19
+ return { connect: jest.fn().mockResolvedValue({ query: clientQuery, release: jest.fn() }) } as unknown as Pool;
20
+ }
21
+
22
+ describe("PostgreSQLProjectMembershipStore", () => {
23
+ beforeEach(() => query.mockReset());
24
+
25
+ it("lists deterministically and finds by exact tenant/project/user", async () => {
26
+ query.mockResolvedValueOnce({ rows: [membership] }).mockResolvedValueOnce({ rows: [membership] });
27
+ const store = new PostgreSQLProjectMembershipStore({ pool });
28
+ await expect(store.list("tenant-1", "project-1")).resolves.toHaveLength(1);
29
+ await expect(store.findByUser("tenant-1", "project-1", "user-1")).resolves.toMatchObject({ id: "member-1" });
30
+ expect(query.mock.calls[0][0]).toContain("ORDER BY joined_at ASC, id ASC");
31
+ expect(query.mock.calls[1][0]).toContain("tenant_id = $1 AND project_id = $2 AND user_id = $3");
32
+ });
33
+
34
+ it("maps membership and tenant-wide ID unique violations accurately", async () => {
35
+ const firstClientQuery = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({})
36
+ .mockRejectedValueOnce(Object.assign(new Error("duplicate"), { code: "23505", constraint: "lattice_project_memberships_tenant_id_project_id_user_id_key" }));
37
+ const store = new PostgreSQLProjectMembershipStore({ pool: transactionPool(firstClientQuery) });
38
+ await expect(store.create({ ...membership, joinedAt: undefined as never, updatedAt: undefined as never })).rejects.toBeInstanceOf(DuplicateProjectMembershipError);
39
+
40
+ const secondClientQuery = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({})
41
+ .mockRejectedValueOnce(Object.assign(new Error("duplicate"), { code: "23505", constraint: "lattice_project_memberships_pkey" }));
42
+ const secondStore = new PostgreSQLProjectMembershipStore({ pool: transactionPool(secondClientQuery) });
43
+ await expect(secondStore.create({ ...membership, id: "same", joinedAt: undefined as never, updatedAt: undefined as never })).rejects.toBeInstanceOf(ProjectMembershipIdConflictError);
44
+ });
45
+
46
+ it("serializes ordinary creates with the project membership write lock", async () => {
47
+ const clientQuery = jest.fn()
48
+ .mockResolvedValueOnce({})
49
+ .mockResolvedValueOnce({})
50
+ .mockResolvedValueOnce({ rows: [membership] })
51
+ .mockResolvedValueOnce({});
52
+ const release = jest.fn();
53
+ const store = new PostgreSQLProjectMembershipStore({
54
+ pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool,
55
+ });
56
+
57
+ await expect(store.create({ ...membership, joinedAt: undefined as never, updatedAt: undefined as never }))
58
+ .resolves.toMatchObject({ id: "member-1" });
59
+ expect(clientQuery.mock.calls.map((call) => String(call[0]))).toEqual([
60
+ "BEGIN",
61
+ expect.stringContaining("pg_advisory_xact_lock"),
62
+ expect.stringContaining("INSERT INTO"),
63
+ "COMMIT",
64
+ ]);
65
+ expect(release).toHaveBeenCalled();
66
+ expect(clientQuery.mock.calls[2][0]).toContain("date_trunc('milliseconds', clock_timestamp())");
67
+ });
68
+
69
+ it("rolls back and releases an ordinary create transaction on failure", async () => {
70
+ const clientQuery = jest.fn()
71
+ .mockResolvedValueOnce({})
72
+ .mockResolvedValueOnce({})
73
+ .mockRejectedValueOnce(new Error("broken"));
74
+ const release = jest.fn();
75
+ const store = new PostgreSQLProjectMembershipStore({
76
+ pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool,
77
+ });
78
+
79
+ await expect(store.create({ ...membership, joinedAt: undefined as never, updatedAt: undefined as never }))
80
+ .rejects.toThrow("broken");
81
+ expect(clientQuery.mock.calls.map((call) => call[0])).toContain("ROLLBACK");
82
+ expect(release).toHaveBeenCalled();
83
+ });
84
+
85
+ it("initializes only an empty project under its deterministic advisory lock", async () => {
86
+ const clientQuery = jest.fn()
87
+ .mockResolvedValueOnce({})
88
+ .mockResolvedValueOnce({})
89
+ .mockResolvedValueOnce({ rows: [] })
90
+ .mockResolvedValueOnce({ rows: [] })
91
+ .mockResolvedValueOnce({ rows: [membership] })
92
+ .mockResolvedValueOnce({});
93
+ const store = new PostgreSQLProjectMembershipStore({ pool: transactionPool(clientQuery) });
94
+ await expect(store.createInitialOwner({ id: "member-1", tenantId: "tenant-1", projectId: "project-1", userId: "user-1" })).resolves.toEqual({ kind: "created", membership: expect.objectContaining({ id: "member-1" }) });
95
+ expect(clientQuery.mock.calls.map((call) => String(call[0]))).toEqual(expect.arrayContaining(["BEGIN", expect.stringContaining("pg_advisory_xact_lock"), expect.stringContaining("SELECT"), expect.stringContaining("INSERT INTO"), "COMMIT"]));
96
+ expect(clientQuery.mock.calls[2][0]).toContain("FOR UPDATE");
97
+ });
98
+
99
+ it.each(["create", "createInitialOwner"] as const)("rolls back malformed returning rows before commit for %s", async (operation) => {
100
+ const malformed = { ...membership, role: "bad" };
101
+ const clientQuery = jest.fn().mockImplementation((sql: string) => {
102
+ if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") return Promise.resolve({});
103
+ if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
104
+ if (sql.includes("project_id") && sql.includes("FOR UPDATE")) return Promise.resolve({ rows: [] });
105
+ if (sql.startsWith("SELECT id FROM")) return Promise.resolve({ rows: [] });
106
+ if (sql.includes("INSERT INTO")) return Promise.resolve({ rows: [malformed] });
107
+ throw new Error(`unexpected SQL: ${sql}`);
108
+ });
109
+ const release = jest.fn();
110
+ const store = new PostgreSQLProjectMembershipStore({ pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool });
111
+ const result = operation === "create"
112
+ ? store.create({ ...membership, joinedAt: undefined as never, updatedAt: undefined as never })
113
+ : store.createInitialOwner({ id: membership.id, tenantId: membership.tenant_id, projectId: membership.project_id, userId: membership.user_id });
114
+ await expect(result).rejects.toThrow("Invalid project membership row");
115
+ expect(clientQuery.mock.calls.map((call) => call[0])).toContain("ROLLBACK");
116
+ expect(clientQuery.mock.calls.map((call) => call[0])).not.toContain("COMMIT");
117
+ expect(release).toHaveBeenCalled();
118
+ });
119
+
120
+ it("rolls back and releases an initialization transaction on failure", async () => {
121
+ const release = jest.fn();
122
+ const clientQuery = jest.fn().mockResolvedValueOnce({}).mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("broken"));
123
+ const transaction = { query: clientQuery, release };
124
+ const store = new PostgreSQLProjectMembershipStore({ pool: { connect: jest.fn().mockResolvedValue(transaction) } as unknown as Pool });
125
+ await expect(store.createInitialOwner({ id: "member-1", tenantId: "tenant-1", projectId: "project-1", userId: "user-1" })).rejects.toThrow("broken");
126
+ expect(clientQuery.mock.calls.map((call) => call[0])).toContain("ROLLBACK");
127
+ expect(release).toHaveBeenCalled();
128
+ });
129
+
130
+ it("maps an initial-owner tenant-wide ID collision", async () => {
131
+ const clientQuery = jest.fn()
132
+ .mockResolvedValueOnce({})
133
+ .mockResolvedValueOnce({})
134
+ .mockResolvedValueOnce({ rows: [] })
135
+ .mockResolvedValueOnce({ rows: [] })
136
+ .mockRejectedValueOnce(Object.assign(new Error("duplicate"), { code: "23505", constraint: "lattice_project_memberships_pkey" }));
137
+ const store = new PostgreSQLProjectMembershipStore({ pool: transactionPool(clientQuery) });
138
+ await expect(store.createInitialOwner({ id: "member-1", tenantId: "tenant-1", projectId: "project-1", userId: "user-1" })).rejects.toBeInstanceOf(ProjectMembershipIdConflictError);
139
+ expect(clientQuery.mock.calls.map((call) => call[0])).toContain("ROLLBACK");
140
+ });
141
+
142
+ it("returns not_found without updating an absent membership", async () => {
143
+ const clientQuery = jest.fn().mockImplementation((sql: string) => {
144
+ if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
145
+ if (sql.includes("project_id FROM")) return Promise.resolve({ rows: [] });
146
+ throw new Error(`unexpected SQL: ${sql}`);
147
+ });
148
+ const store = new PostgreSQLProjectMembershipStore({ pool: transactionPool(clientQuery) });
149
+ await expect(store.updateRole({ tenantId: "tenant-1", id: "member-1", role: "admin", expectedUpdatedAt: now })).resolves.toEqual({ kind: "not_found" });
150
+ });
151
+
152
+ it("returns last_owner without updating the sole active owner", async () => {
153
+ const clientQuery = jest.fn().mockImplementation((sql: string) => {
154
+ if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
155
+ if (sql.includes("project_id FROM")) return Promise.resolve({ rows: [membership] });
156
+ if (sql.includes("active")) return Promise.resolve({ rows: [] });
157
+ if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
158
+ if (sql.includes("FOR UPDATE")) return Promise.resolve({ rows: [membership] });
159
+ throw new Error(`unexpected SQL: ${sql}`);
160
+ });
161
+ const store = new PostgreSQLProjectMembershipStore({ pool: transactionPool(clientQuery) });
162
+ await expect(store.remove({ tenantId: "tenant-1", id: "member-1", expectedUpdatedAt: now })).resolves.toEqual({ kind: "last_owner" });
163
+ expect(clientQuery.mock.calls.some((call) => String(call[0]).trimStart().startsWith("UPDATE"))).toBe(false);
164
+ expect(clientQuery.mock.calls.some((call) => String(call[0]).includes("active") && String(call[0]).includes("FOR UPDATE"))).toBe(true);
165
+ });
166
+
167
+ it("uses exact updated_at CAS for a successful role update", async () => {
168
+ const otherOwner = { ...membership, id: "member-2", user_id: "user-2" };
169
+ const updated = { ...membership, role: "admin", updated_at: new Date("2026-08-30T12:00:00.001Z") };
170
+ const clientQuery = jest.fn().mockImplementation((sql: string) => {
171
+ if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
172
+ if (sql.includes("project_id FROM")) return Promise.resolve({ rows: [membership] });
173
+ if (sql.includes("active")) return Promise.resolve({ rows: [membership, otherOwner] });
174
+ if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
175
+ if (sql.includes("UPDATE")) return Promise.resolve({ rows: [updated] });
176
+ throw new Error(`unexpected SQL: ${sql}`);
177
+ });
178
+ const store = new PostgreSQLProjectMembershipStore({ pool: transactionPool(clientQuery) });
179
+ await expect(store.updateRole({ tenantId: "tenant-1", id: "member-1", role: "admin", expectedUpdatedAt: now })).resolves.toMatchObject({ kind: "updated", membership: { role: "admin" } });
180
+ const updateCall = clientQuery.mock.calls.find((call) => String(call[0]).trimStart().startsWith("UPDATE")) as [string, unknown[]];
181
+ expect(updateCall[1]).toContain(now);
182
+ expect(updateCall[1]).toEqual(["tenant-1", "member-1", now, "admin"]);
183
+ expect(updateCall[0]).toContain("date_trunc('milliseconds', updated_at) = $3");
184
+ expect(updateCall[0]).toContain("date_trunc('milliseconds', clock_timestamp())");
185
+ expect(clientQuery.mock.calls.some((call) => String(call[0]).includes("active") && String(call[0]).includes("FOR UPDATE"))).toBe(true);
186
+ });
187
+
188
+ it("uses monotonic millisecond revision SQL for removal", async () => {
189
+ const clientQuery = jest.fn().mockImplementation((sql: string) => {
190
+ if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
191
+ if (sql.includes("project_id FROM")) return Promise.resolve({ rows: [membership] });
192
+ if (sql.includes("active")) return Promise.resolve({ rows: [membership, { ...membership, id: "member-2" }] });
193
+ if (sql.includes("UPDATE")) return Promise.resolve({ rows: [{ ...membership, status: "removed", updated_at: new Date(now.getTime() + 1) }] });
194
+ if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
195
+ throw new Error(`unexpected SQL: ${sql}`);
196
+ });
197
+ const store = new PostgreSQLProjectMembershipStore({ pool: transactionPool(clientQuery) });
198
+ await expect(store.remove({ tenantId: "tenant-1", id: "member-1", expectedUpdatedAt: now })).resolves.toMatchObject({ kind: "removed", membership: { status: "removed" } });
199
+ const updateCall = clientQuery.mock.calls.find((call) => String(call[0]).trimStart().startsWith("UPDATE")) as [string, unknown[]];
200
+ expect(updateCall[0]).toContain("GREATEST(date_trunc('milliseconds', updated_at) + interval '1 millisecond', date_trunc('milliseconds', clock_timestamp()))");
201
+ expect(updateCall[0]).toContain("date_trunc('milliseconds', updated_at) = $3");
202
+ expect(updateCall[1]).toEqual(["tenant-1", "member-1", now]);
203
+ });
204
+
205
+ it.each(["updateRole", "remove"] as const)("validates every locked active row before %s", async (operation) => {
206
+ const malformed = { ...membership, id: "member-2", role: "invalid" };
207
+ const clientQuery = jest.fn().mockImplementation((sql: string) => {
208
+ if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") return Promise.resolve({});
209
+ if (sql.includes("project_id FROM")) return Promise.resolve({ rows: [membership] });
210
+ if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
211
+ if (sql.includes("status = 'active'") && sql.includes("FOR UPDATE")) {
212
+ return Promise.resolve({ rows: [membership, malformed] });
213
+ }
214
+ throw new Error(`unexpected SQL: ${sql}`);
215
+ });
216
+ const release = jest.fn();
217
+ const store = new PostgreSQLProjectMembershipStore({
218
+ pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool,
219
+ });
220
+
221
+ const result = operation === "updateRole"
222
+ ? store.updateRole({ tenantId: "tenant-1", id: "member-1", role: "admin", expectedUpdatedAt: now })
223
+ : store.remove({ tenantId: "tenant-1", id: "member-1", expectedUpdatedAt: now });
224
+ await expect(result).rejects.toThrow("Invalid project membership row");
225
+ expect(clientQuery.mock.calls.map((call) => call[0])).toContain("ROLLBACK");
226
+ expect(clientQuery.mock.calls.some((call) => String(call[0]).trimStart().startsWith("UPDATE"))).toBe(false);
227
+ expect(release).toHaveBeenCalled();
228
+ });
229
+
230
+ it("rejects malformed membership rows", async () => {
231
+ query.mockResolvedValueOnce({ rows: [{ ...membership, role: "invalid" }] });
232
+ const store = new PostgreSQLProjectMembershipStore({ pool });
233
+ await expect(store.list("tenant-1", "project-1")).rejects.toThrow("Invalid project membership row");
234
+ });
235
+ });
@@ -0,0 +1,103 @@
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import type { Pool } from "pg";
3
+
4
+ import {
5
+ DuplicateProjectRoomMessageIdempotencyKeyError,
6
+ PostgreSQLProjectRoomMessageStore,
7
+ ProjectRoomMessageIdConflictError,
8
+ } from "../stores/PostgreSQLProjectRoomMessageStore";
9
+
10
+ const query = jest.fn();
11
+ const pool = { query } as unknown as Pool;
12
+ const now = new Date("2026-08-30T12:00:00.000Z");
13
+ const row = {
14
+ id: "message-1", tenant_id: "tenant-1", workspace_id: "workspace-1", project_id: "project-1", room_id: "room-1",
15
+ author: { type: "human", userId: "user-1" }, content: { type: "text", text: "Hello" },
16
+ mentions: [{ type: "team" }], reply_to_message_id: null, source: "user", source_id: null,
17
+ idempotency_key: null, created_at: now,
18
+ };
19
+
20
+ describe("PostgreSQLProjectRoomMessageStore", () => {
21
+ beforeEach(() => query.mockReset());
22
+
23
+ it("creates with an explicit millisecond timestamp and exact parameters", async () => {
24
+ query.mockResolvedValueOnce({ rows: [row] });
25
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
26
+ await expect(store.create({
27
+ id: "message-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
28
+ author: { type: "human", userId: "user-1" }, content: { type: "text", text: "Hello" }, mentions: [{ type: "team" }], source: "user",
29
+ })).resolves.toMatchObject({ id: "message-1", createdAt: now });
30
+ expect(query.mock.calls[0][0]).toContain("date_trunc('milliseconds', clock_timestamp())");
31
+ expect(query.mock.calls[0][1]).toEqual(["message-1", "tenant-1", "workspace-1", "project-1", "room-1", JSON.stringify({ type: "human", userId: "user-1" }), JSON.stringify({ type: "text", text: "Hello" }), JSON.stringify([{ type: "team" }]), null, "user", null, null]);
32
+ });
33
+
34
+ it.each([
35
+ ["lattice_project_room_messages_pkey", ProjectRoomMessageIdConflictError],
36
+ ["uq_lattice_project_room_messages_idempotency", DuplicateProjectRoomMessageIdempotencyKeyError],
37
+ ])("maps ordinary create constraint %s", async (constraint, ErrorType) => {
38
+ query.mockRejectedValueOnce(Object.assign(new Error("raw sql"), { code: "23505", constraint }));
39
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
40
+ await expect(store.create({
41
+ id: "message-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
42
+ author: { type: "system" }, content: { type: "text", text: "Hello" }, mentions: [], source: "system", idempotencyKey: "key-1",
43
+ })).rejects.toBeInstanceOf(ErrorType);
44
+ });
45
+
46
+ it("returns the canonical row atomically for an idempotency conflict without semantic changes", async () => {
47
+ query.mockResolvedValueOnce({ rows: [{ ...row, id: "canonical", idempotency_key: "key-1" }] });
48
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
49
+ await expect(store.createIdempotent({
50
+ id: "candidate", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
51
+ author: { type: "system" }, content: { type: "text", text: "Different" }, mentions: [], source: "system", idempotencyKey: "key-1",
52
+ })).resolves.toMatchObject({ id: "canonical", content: { text: "Hello" } });
53
+ const [sql, params] = query.mock.calls[0] as [string, unknown[]];
54
+ expect(sql).toContain("ON CONFLICT (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL");
55
+ expect(sql).toContain("DO UPDATE SET idempotency_key = lattice_project_room_messages.idempotency_key");
56
+ expect(params[0]).toBe("candidate");
57
+ });
58
+
59
+ it("maps a candidate ID collision during idempotent creation", async () => {
60
+ query.mockRejectedValueOnce(Object.assign(new Error("duplicate"), { code: "23505", constraint: "lattice_project_room_messages_pkey" }));
61
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
62
+ await expect(store.createIdempotent({
63
+ id: "candidate", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", roomId: "room-1",
64
+ author: { type: "system" }, content: { type: "text", text: "Hello" }, mentions: [], source: "system", idempotencyKey: "key-1",
65
+ })).rejects.toBeInstanceOf(ProjectRoomMessageIdConflictError);
66
+ });
67
+
68
+ it("lists with exact descending cursor SQL and clamps the limit", async () => {
69
+ query.mockResolvedValueOnce({ rows: [row] }).mockResolvedValueOnce({ rows: [] });
70
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
71
+ await store.list({ tenantId: "tenant-1", roomId: "room-1", before: { createdAt: now, id: "message-2" }, limit: 500 });
72
+ expect(query.mock.calls[0][0]).toContain("created_at < $3 OR (created_at = $3 AND id < $4)");
73
+ expect(query.mock.calls[0][0]).toContain("ORDER BY created_at DESC, id DESC LIMIT $5");
74
+ expect(query.mock.calls[0][1]).toEqual(["tenant-1", "room-1", now, "message-2", 100]);
75
+ await store.list({ tenantId: "tenant-1", roomId: "room-1", limit: 0 });
76
+ expect(query.mock.calls[1][1]).toEqual(["tenant-1", "room-1", 1]);
77
+ });
78
+
79
+ it("rejects an invalid cursor date before querying", async () => {
80
+ const store = new PostgreSQLProjectRoomMessageStore({ pool });
81
+ await expect(store.list({ tenantId: "tenant-1", roomId: "room-1", before: { createdAt: new Date("bad"), id: "x" }, limit: 10 }))
82
+ .rejects.toThrow(RangeError);
83
+ expect(query).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("finds by exact tenant and ID", async () => {
87
+ query.mockResolvedValueOnce({ rows: [row] });
88
+ await expect(new PostgreSQLProjectRoomMessageStore({ pool }).findById("tenant-1", "message-1"))
89
+ .resolves.toMatchObject({ id: "message-1" });
90
+ expect(query.mock.calls[0][1]).toEqual(["tenant-1", "message-1"]);
91
+ });
92
+
93
+ it.each([
94
+ { author: { type: "human", userId: 1 } },
95
+ { content: { type: "html", text: "x" } },
96
+ { mentions: [{ type: "bot" }] },
97
+ { source: "external" },
98
+ ])("rejects malformed JSON or discriminants %#", async (patch) => {
99
+ query.mockResolvedValueOnce({ rows: [{ ...row, ...patch }] });
100
+ await expect(new PostgreSQLProjectRoomMessageStore({ pool }).findById("tenant-1", "message-1"))
101
+ .rejects.toThrow("Invalid project room message row");
102
+ });
103
+ });