@axiom-lattice/pg-stores 3.1.0 → 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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +18 -0
- package/dist/index.d.mts +283 -23
- package/dist/index.d.ts +283 -23
- package/dist/index.js +1867 -224
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1854 -217
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ChannelBindingStore.test.ts +122 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.migrations.test.ts +67 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +383 -0
- package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
- package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
- package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
- package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
- package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
- package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +107 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +162 -1
- package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +209 -4
- package/src/__tests__/add_workspace_project_to_queue.test.ts +15 -0
- package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
- package/src/__tests__/task-files.test.ts +4 -3
- package/src/createPgStoreConfig.ts +25 -1
- package/src/index.ts +13 -0
- package/src/migrations/add_trusted_run_context_column.ts +18 -0
- package/src/migrations/capability_bundle_migration.ts +20 -0
- package/src/migrations/migration.ts +2 -1
- package/src/migrations/project_room_migration.ts +128 -0
- package/src/migrations/task_migration.ts +15 -0
- package/src/migrations/task_work_items_migration.ts +45 -1
- package/src/stores/ChannelBindingStore.ts +99 -59
- package/src/stores/PostgreSQLCapabilityBundleStore.ts +306 -0
- package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
- package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
- package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
- package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
- package/src/stores/PostgreSQLProjectStore.ts +230 -50
- package/src/stores/PostgreSQLTaskStore.ts +89 -3
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +198 -8
- package/src/stores/ThreadMessageQueueStore.ts +130 -32
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|