@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/pg-stores",
|
|
3
|
-
"version": "3.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.
|
|
29
|
-
"@axiom-lattice/protocols": "4.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
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
|
|
3
|
+
const recordedMigrationNames: string[] = [];
|
|
4
|
+
const clientSql: string[] = [];
|
|
5
|
+
let migrationError: Error | null = null;
|
|
6
|
+
|
|
7
|
+
jest.mock("pg", () => {
|
|
8
|
+
const client = {
|
|
9
|
+
query: async (sql: string, params?: unknown[]) => {
|
|
10
|
+
clientSql.push(sql);
|
|
11
|
+
if (migrationError && sql.includes("CREATE TABLE IF NOT EXISTS lattice_capability_bundles")) {
|
|
12
|
+
throw migrationError;
|
|
13
|
+
}
|
|
14
|
+
if (sql.includes("information_schema.tables")) return { rows: [{ exists: true }] };
|
|
15
|
+
if (sql.includes("pg_index")) return { rows: [] };
|
|
16
|
+
if (sql.includes("INSERT INTO lattice_schema_migrations")) {
|
|
17
|
+
recordedMigrationNames.push(params?.[0] as string);
|
|
18
|
+
}
|
|
19
|
+
return { rows: [] };
|
|
20
|
+
},
|
|
21
|
+
release: () => undefined,
|
|
22
|
+
};
|
|
23
|
+
const pool = {
|
|
24
|
+
connect: async () => client,
|
|
25
|
+
query: async () => ({ rows: [] }),
|
|
26
|
+
end: async () => undefined,
|
|
27
|
+
};
|
|
28
|
+
return { Pool: function Pool() { return pool; } };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
import { PostgreSQLCapabilityBundleStore } from "../stores/PostgreSQLCapabilityBundleStore";
|
|
32
|
+
|
|
33
|
+
describe("PostgreSQLCapabilityBundleStore standalone migrations", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
recordedMigrationNames.length = 0;
|
|
36
|
+
clientSql.length = 0;
|
|
37
|
+
migrationError = null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("remains uninitialized with autoMigrate false until initialize applies its migration", async () => {
|
|
41
|
+
const store = new PostgreSQLCapabilityBundleStore({
|
|
42
|
+
poolConfig: "postgres://unused",
|
|
43
|
+
autoMigrate: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(clientSql).toEqual([]);
|
|
47
|
+
await store.initialize();
|
|
48
|
+
|
|
49
|
+
expect(recordedMigrationNames).toEqual(["create_capability_bundles_table"]);
|
|
50
|
+
expect(clientSql.some((sql) =>
|
|
51
|
+
sql.includes("CREATE TABLE IF NOT EXISTS lattice_capability_bundles")
|
|
52
|
+
)).toBe(true);
|
|
53
|
+
await store.dispose();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("shares an eager migration rejection with initialize and operations without retrying", async () => {
|
|
57
|
+
migrationError = new Error("migration failed");
|
|
58
|
+
const store = new PostgreSQLCapabilityBundleStore({ poolConfig: "postgres://unused" });
|
|
59
|
+
|
|
60
|
+
await expect(store.initialize()).rejects.toThrow("migration failed");
|
|
61
|
+
await expect(store.listByTenant("tenant-1")).rejects.toThrow("migration failed");
|
|
62
|
+
expect(clientSql.filter((sql) =>
|
|
63
|
+
sql.includes("CREATE TABLE IF NOT EXISTS lattice_capability_bundles")
|
|
64
|
+
)).toHaveLength(1);
|
|
65
|
+
await store.dispose();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
import { createCapabilityBundlesTable } from "../migrations/capability_bundle_migration";
|
|
5
|
+
import { PostgreSQLCapabilityBundleStore } from "../stores/PostgreSQLCapabilityBundleStore";
|
|
6
|
+
import { PostgreSQLProjectStore } from "../stores/PostgreSQLProjectStore";
|
|
7
|
+
|
|
8
|
+
const mockQuery = jest.fn();
|
|
9
|
+
const now = new Date("2026-08-27T12:00:00.000Z");
|
|
10
|
+
const capabilities = [{
|
|
11
|
+
id: "filesystem",
|
|
12
|
+
type: "filesystem",
|
|
13
|
+
name: "Filesystem",
|
|
14
|
+
enabled: true,
|
|
15
|
+
config: { roots: ["/workspace"], policy: { write: false } },
|
|
16
|
+
}];
|
|
17
|
+
const row = {
|
|
18
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
19
|
+
tenant_id: "tenant-1",
|
|
20
|
+
bundle_key: "research",
|
|
21
|
+
name: "Research",
|
|
22
|
+
description: "Research tools",
|
|
23
|
+
capabilities,
|
|
24
|
+
created_at: now,
|
|
25
|
+
updated_at: now,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("PostgreSQLCapabilityBundleStore", () => {
|
|
29
|
+
const pool = { query: mockQuery } as unknown as Pool;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => mockQuery.mockReset());
|
|
32
|
+
|
|
33
|
+
it("creates with a generated UUID and round-trips nested JSONB", async () => {
|
|
34
|
+
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
35
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
36
|
+
const result = await store.create("tenant-1", {
|
|
37
|
+
key: "research", name: "Research", description: "Research tools", capabilities,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
41
|
+
expect(sql).toContain("INSERT INTO lattice_capability_bundles");
|
|
42
|
+
expect(params[0]).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/));
|
|
43
|
+
expect(params.at(-1)).toBe(JSON.stringify(capabilities));
|
|
44
|
+
expect(result).toEqual({
|
|
45
|
+
id: row.id, tenantId: "tenant-1", key: "research", name: "Research",
|
|
46
|
+
description: "Research tools", capabilities, createdAt: now.toISOString(), updatedAt: now.toISOString(),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("performs tenant-scoped list, single, bulk, and update queries", async () => {
|
|
51
|
+
mockQuery
|
|
52
|
+
.mockResolvedValueOnce({ rows: [row] })
|
|
53
|
+
.mockResolvedValueOnce({ rows: [row] })
|
|
54
|
+
.mockResolvedValueOnce({ rows: [row] })
|
|
55
|
+
.mockResolvedValueOnce({ rows: [{ ...row, name: "Updated", updated_at: new Date(now.getTime() + 1) }] });
|
|
56
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
57
|
+
|
|
58
|
+
await expect(store.listByTenant("tenant-1")).resolves.toHaveLength(1);
|
|
59
|
+
await expect(store.getById("tenant-1", row.id)).resolves.toMatchObject({ id: row.id });
|
|
60
|
+
await expect(store.getManyByIds("tenant-1", [row.id])).resolves.toHaveLength(1);
|
|
61
|
+
const updated = await store.update("tenant-1", row.id, { name: "Updated" });
|
|
62
|
+
expect(updated).toMatchObject({ id: row.id, createdAt: now.toISOString(), name: "Updated" });
|
|
63
|
+
expect("delete" in store).toBe(false);
|
|
64
|
+
|
|
65
|
+
for (const call of mockQuery.mock.calls) expect(call[1]).toContain("tenant-1");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("normalizes PostgreSQL unique violations to the stable conflict message", async () => {
|
|
69
|
+
mockQuery.mockRejectedValueOnce(Object.assign(new Error("duplicate"), { code: "23505" }));
|
|
70
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
71
|
+
await expect(store.create("tenant-1", {
|
|
72
|
+
key: "research", name: "Research", capabilities,
|
|
73
|
+
})).rejects.toThrow("Capability bundle key already exists for tenant");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("normalizes PostgreSQL unique violations raised while updating a key", async () => {
|
|
77
|
+
mockQuery.mockRejectedValueOnce(Object.assign(new Error("duplicate"), { code: "23505" }));
|
|
78
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
79
|
+
|
|
80
|
+
await expect(store.update("tenant-1", row.id, { key: "existing-key" })).rejects.toThrow(
|
|
81
|
+
"Capability bundle key already exists for tenant",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("uses exact updated_at CAS and distinguishes a stale update", async () => {
|
|
86
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
87
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
88
|
+
await expect(store.update("tenant-1", row.id, { name: "Stale", expectedUpdatedAt: "2026-08-27T12:00:00.000Z" }))
|
|
89
|
+
.resolves.toEqual({ status: "conflict" });
|
|
90
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
91
|
+
expect(sql).toContain("updated_at::text =");
|
|
92
|
+
expect(sql).toContain("GREATEST(updated_at + interval '1 microsecond', clock_timestamp())");
|
|
93
|
+
expect(params).toContain("2026-08-27T12:00:00.000Z");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("checks CAS tokens for no-op updates", async () => {
|
|
97
|
+
mockQuery.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [row] });
|
|
98
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
99
|
+
|
|
100
|
+
await expect(store.update("tenant-1", row.id, { expectedUpdatedAt: "stale" }))
|
|
101
|
+
.resolves.toEqual({ status: "conflict" });
|
|
102
|
+
await expect(store.update("tenant-1", row.id, { expectedUpdatedAt: now.toISOString() }))
|
|
103
|
+
.resolves.toMatchObject({ id: row.id, updatedAt: now.toISOString() });
|
|
104
|
+
expect(mockQuery.mock.calls[0][0]).toContain("updated_at::text");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it.each([
|
|
108
|
+
[[], [], "not_found"],
|
|
109
|
+
[[{ exists: 1 }], [], "in_use"],
|
|
110
|
+
[[], [{ id: row.id }], "deleted"],
|
|
111
|
+
])("atomically maps conditional delete status to %s", async (referenceRows, deleteRows, expected) => {
|
|
112
|
+
const clientQuery = jest.fn().mockImplementation((sql: string) => {
|
|
113
|
+
if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
|
|
114
|
+
if (sql.includes("SELECT id FROM lattice_projects")) return Promise.resolve({ rows: [] });
|
|
115
|
+
if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
|
|
116
|
+
if (sql.includes("SELECT 1 FROM lattice_projects")) return Promise.resolve({ rows: referenceRows });
|
|
117
|
+
if (sql.includes("DELETE FROM lattice_capability_bundles")) return Promise.resolve({ rows: deleteRows });
|
|
118
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
119
|
+
});
|
|
120
|
+
const release = jest.fn();
|
|
121
|
+
const transactionPool = {
|
|
122
|
+
connect: jest.fn().mockResolvedValue({ query: clientQuery, release }),
|
|
123
|
+
} as unknown as Pool;
|
|
124
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool: transactionPool });
|
|
125
|
+
|
|
126
|
+
await expect(store.deleteIfUnreferenced("tenant-1", row.id)).resolves.toBe(expected);
|
|
127
|
+
expect(clientQuery.mock.calls.some((call) => String(call[0]).includes("pg_advisory_xact_lock"))).toBe(true);
|
|
128
|
+
expect(clientQuery.mock.calls.some((call) => String(call[0]).includes("lattice_projects"))).toBe(true);
|
|
129
|
+
expect(clientQuery.mock.calls.some((call) => String(call[0]).includes("DELETE FROM lattice_capability_bundles"))).toBe(expected !== "in_use");
|
|
130
|
+
expect(release).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("defines idempotent migration version 170 with the tenant index", async () => {
|
|
134
|
+
const query = jest.fn().mockResolvedValue({});
|
|
135
|
+
await createCapabilityBundlesTable.up({ query } as never);
|
|
136
|
+
const sql = query.mock.calls.map((call) => String(call[0])).join("\n");
|
|
137
|
+
expect(createCapabilityBundlesTable.version).toBe(170);
|
|
138
|
+
expect(createCapabilityBundlesTable.name).toBe("create_capability_bundles_table");
|
|
139
|
+
expect(sql).toContain("CREATE TABLE IF NOT EXISTS lattice_capability_bundles");
|
|
140
|
+
expect(sql).toContain("UNIQUE (tenant_id, bundle_key)");
|
|
141
|
+
expect(sql).toContain("CREATE INDEX IF NOT EXISTS");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("PostgreSQLProjectStore capability bundle references", () => {
|
|
146
|
+
beforeEach(() => mockQuery.mockReset());
|
|
147
|
+
it("rejects capability bundle references through generic project writes", async () => {
|
|
148
|
+
const store = new PostgreSQLProjectStore({ pool: { query: mockQuery } as unknown as Pool });
|
|
149
|
+
|
|
150
|
+
await expect(store.createProject("tenant-1", "workspace", "project", {
|
|
151
|
+
name: "Project", config: { capabilityBundleIds: [] },
|
|
152
|
+
})).rejects.toMatchObject({ code: "INVALID_BUNDLE_CONFIG" });
|
|
153
|
+
await expect(store.updateProject("tenant-1", "project", {
|
|
154
|
+
config: { capabilityBundleIds: [] },
|
|
155
|
+
})).rejects.toMatchObject({ code: "INVALID_BUNDLE_CONFIG" });
|
|
156
|
+
expect(mockQuery).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("preserves current-row capability bundle IDs during generic config replacement", async () => {
|
|
160
|
+
const clientQuery = jest.fn().mockImplementation((sql: string) => {
|
|
161
|
+
if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
|
|
162
|
+
if (sql.includes("FOR UPDATE")) return Promise.resolve({ rows: [{ config: { capabilityBundleIds: ["b1"] } }] });
|
|
163
|
+
if (sql.includes("UPDATE lattice_projects")) return Promise.resolve({ rows: [{ ...row, config: { replacement: true, capabilityBundleIds: ["b1"] } }] });
|
|
164
|
+
if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
|
|
165
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
166
|
+
});
|
|
167
|
+
const store = new PostgreSQLProjectStore({ pool: {
|
|
168
|
+
connect: jest.fn().mockResolvedValue({ query: clientQuery, release: jest.fn() }),
|
|
169
|
+
} as unknown as Pool });
|
|
170
|
+
|
|
171
|
+
await store.updateProject("tenant-1", row.id, { config: { replacement: true } });
|
|
172
|
+
|
|
173
|
+
const [sql, params] = clientQuery.mock.calls[5] as [string, unknown[]];
|
|
174
|
+
expect(sql).toContain("config ? 'capabilityBundleIds'");
|
|
175
|
+
expect(sql).toContain("config->'capabilityBundleIds'");
|
|
176
|
+
expect(sql).toContain("jsonb_build_object('capabilityBundleIds'");
|
|
177
|
+
expect(params).toContainEqual({ replacement: true });
|
|
178
|
+
expect(params).not.toContainEqual(expect.objectContaining({ capabilityBundleIds: expect.anything() }));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("locks the project row before sorted preserved bundles during a generic update", async () => {
|
|
182
|
+
const clientQuery = jest.fn().mockImplementation((sql: string) => {
|
|
183
|
+
if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
|
|
184
|
+
if (sql.includes("FOR UPDATE")) return Promise.resolve({ rows: [{ config: { capabilityBundleIds: ["bundle-z", "bundle-a"] } }] });
|
|
185
|
+
if (sql.includes("UPDATE lattice_projects")) return Promise.resolve({ rows: [{ ...row, config: { replacement: true, capabilityBundleIds: ["bundle-z", "bundle-a"] } }] });
|
|
186
|
+
if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
|
|
187
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
188
|
+
});
|
|
189
|
+
const release = jest.fn();
|
|
190
|
+
const pool = {
|
|
191
|
+
query: mockQuery,
|
|
192
|
+
connect: jest.fn().mockResolvedValue({ query: clientQuery, release }),
|
|
193
|
+
} as unknown as Pool;
|
|
194
|
+
const store = new PostgreSQLProjectStore({ pool });
|
|
195
|
+
|
|
196
|
+
await expect(store.updateProject("tenant-1", row.id, { config: { replacement: true } }))
|
|
197
|
+
.resolves.toMatchObject({ config: { capabilityBundleIds: ["bundle-z", "bundle-a"] } });
|
|
198
|
+
|
|
199
|
+
expect(clientQuery.mock.calls.map((call) => String(call[0]))).toEqual([
|
|
200
|
+
"BEGIN",
|
|
201
|
+
expect.stringContaining("pg_advisory_xact_lock"),
|
|
202
|
+
expect.stringContaining("pg_advisory_xact_lock"),
|
|
203
|
+
expect.stringContaining("FOR UPDATE"),
|
|
204
|
+
expect.stringContaining("pg_advisory_xact_lock"),
|
|
205
|
+
expect.stringContaining("UPDATE lattice_projects"),
|
|
206
|
+
"COMMIT",
|
|
207
|
+
]);
|
|
208
|
+
expect(clientQuery.mock.calls[4][1]).toEqual(["tenant-1", ["bundle-a", "bundle-z"]]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("locks generic create and preserves current-row capability bundle IDs on conflict", async () => {
|
|
212
|
+
const clientQuery = jest.fn().mockImplementation((sql: string) => {
|
|
213
|
+
if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
|
|
214
|
+
if (sql.includes("FOR UPDATE")) return Promise.resolve({ rows: [{ config: { capabilityBundleIds: ["b1"] } }] });
|
|
215
|
+
if (sql.includes("INSERT INTO lattice_projects")) return Promise.resolve({ rows: [{
|
|
216
|
+
...row, workspace_id: "workspace-2", config: { replacement: true, capabilityBundleIds: ["b1"] },
|
|
217
|
+
}] });
|
|
218
|
+
if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
|
|
219
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
220
|
+
});
|
|
221
|
+
const store = new PostgreSQLProjectStore({ pool: {
|
|
222
|
+
connect: jest.fn().mockResolvedValue({ query: clientQuery, release: jest.fn() }),
|
|
223
|
+
} as unknown as Pool });
|
|
224
|
+
|
|
225
|
+
const result = await store.createProject("tenant-1", "workspace-2", row.id, {
|
|
226
|
+
name: "Upserted",
|
|
227
|
+
config: { replacement: true },
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const [sql, params] = clientQuery.mock.calls[5] as [string, unknown[]];
|
|
231
|
+
expect(sql).toContain("lattice_projects.config ? 'capabilityBundleIds'");
|
|
232
|
+
expect(sql).toContain("lattice_projects.config->'capabilityBundleIds'");
|
|
233
|
+
expect(sql).toContain("RETURNING");
|
|
234
|
+
expect(params).not.toContainEqual(expect.objectContaining({ capabilityBundleIds: expect.anything() }));
|
|
235
|
+
expect(result.config).toEqual({ replacement: true, capabilityBundleIds: ["b1"] });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("atomically merges capability bundle IDs and returns the updated row", async () => {
|
|
239
|
+
const clientQuery = jest.fn().mockImplementation((sql: string) => {
|
|
240
|
+
if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
|
|
241
|
+
if (sql.includes(":project-mutations")) return Promise.resolve({});
|
|
242
|
+
if (sql.includes("FOR UPDATE") && sql.includes("lattice_projects")) return Promise.resolve({ rows: [{ id: "project-1" }] });
|
|
243
|
+
if (sql.includes("UPDATE lattice_projects")) return Promise.resolve({ rows: [{ ...row, config: { keep: true, capabilityBundleIds: ["b1"] } }] });
|
|
244
|
+
if (sql.includes("updated_at") && sql.includes("lattice_capability_bundles")) return Promise.resolve({ rows: [] });
|
|
245
|
+
if (sql.includes("SELECT id FROM lattice_projects")) return Promise.resolve({ rows: [{ id: "project-1" }] });
|
|
246
|
+
if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
|
|
247
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
248
|
+
});
|
|
249
|
+
const release = jest.fn();
|
|
250
|
+
const store = new PostgreSQLProjectStore({
|
|
251
|
+
pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool,
|
|
252
|
+
});
|
|
253
|
+
await expect(store.updateCapabilityBundleIds("tenant-1", "project-1", ["b1"])).resolves.toMatchObject({ status: "updated", project: { id: row.id } });
|
|
254
|
+
const [sql, params] = clientQuery.mock.calls[5] as [string, unknown[]];
|
|
255
|
+
expect(sql).toContain("COALESCE(config, '{}'::jsonb) || jsonb_build_object('capabilityBundleIds', $3::jsonb)");
|
|
256
|
+
expect(sql).toContain("lattice_capability_bundles");
|
|
257
|
+
expect(sql).toContain("RETURNING");
|
|
258
|
+
expect(params).toEqual(["project-1", "tenant-1", '["b1"]', ["b1"]]);
|
|
259
|
+
expect(String(clientQuery.mock.calls[2][0])).toContain("pg_advisory_xact_lock");
|
|
260
|
+
expect(clientQuery.mock.calls[2][1]).toEqual(["project-1", "tenant-1"]);
|
|
261
|
+
expect(String(clientQuery.mock.calls[4][0])).toContain("pg_advisory_xact_lock");
|
|
262
|
+
expect(release).toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("locks referenced bundles while deleting a project", async () => {
|
|
266
|
+
const clientQuery = jest.fn().mockImplementation((sql: string) => {
|
|
267
|
+
if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
|
|
268
|
+
if (sql.includes(":project-mutations")) return Promise.resolve({});
|
|
269
|
+
if (sql.includes("FOR UPDATE")) return Promise.resolve({ rows: [{ config: { capabilityBundleIds: [row.id] } }] });
|
|
270
|
+
if (sql.includes("unnest")) return Promise.resolve({});
|
|
271
|
+
if (sql.includes("DELETE FROM lattice_projects")) return Promise.resolve({ rowCount: 1 });
|
|
272
|
+
if (sql.includes(":project:")) return Promise.resolve({});
|
|
273
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
274
|
+
});
|
|
275
|
+
const release = jest.fn();
|
|
276
|
+
const store = new PostgreSQLProjectStore({
|
|
277
|
+
pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await expect(store.deleteProject("tenant-1", "project-1")).resolves.toBe(true);
|
|
281
|
+
|
|
282
|
+
expect(String(clientQuery.mock.calls[2][0])).toContain("pg_advisory_xact_lock");
|
|
283
|
+
expect(clientQuery.mock.calls[2][1]).toEqual(["project-1", "tenant-1"]);
|
|
284
|
+
expect(String(clientQuery.mock.calls[4][0])).toContain("unnest");
|
|
285
|
+
expect(clientQuery.mock.calls[4][1]).toEqual(["tenant-1", [row.id]]);
|
|
286
|
+
expect(String(clientQuery.mock.calls[5][0])).toContain("DELETE FROM lattice_projects");
|
|
287
|
+
expect(clientQuery).toHaveBeenCalledWith("COMMIT");
|
|
288
|
+
expect(release).toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("locks matching project rows before the bundle during conditional deletion", async () => {
|
|
292
|
+
const clientQuery = jest.fn().mockImplementation((sql: string) => {
|
|
293
|
+
if (sql === "BEGIN" || sql === "COMMIT") return Promise.resolve({});
|
|
294
|
+
if (sql.includes("ORDER BY id")) return Promise.resolve({ rows: [{ id: "project-1" }] });
|
|
295
|
+
if (sql.includes("FOR UPDATE")) return Promise.resolve({ rows: [{ id: "project-1" }] });
|
|
296
|
+
if (sql.includes("SELECT 1 FROM lattice_projects")) return Promise.resolve({ rows: [] });
|
|
297
|
+
if (sql.includes("DELETE FROM lattice_capability_bundles")) return Promise.resolve({ rows: [{ id: row.id }] });
|
|
298
|
+
if (sql.includes("pg_advisory_xact_lock")) return Promise.resolve({});
|
|
299
|
+
throw new Error(`unexpected SQL: ${sql}`);
|
|
300
|
+
});
|
|
301
|
+
const release = jest.fn();
|
|
302
|
+
const pool = {
|
|
303
|
+
connect: jest.fn().mockResolvedValue({ query: clientQuery, release }),
|
|
304
|
+
} as unknown as Pool;
|
|
305
|
+
const store = new PostgreSQLCapabilityBundleStore({ pool });
|
|
306
|
+
|
|
307
|
+
await expect(store.deleteIfUnreferenced("tenant-1", row.id)).resolves.toBe("deleted");
|
|
308
|
+
|
|
309
|
+
expect(clientQuery.mock.calls.map((call) => String(call[0]))).toEqual([
|
|
310
|
+
"BEGIN",
|
|
311
|
+
expect.stringContaining("project-mutations"),
|
|
312
|
+
expect.stringContaining("lattice_projects"),
|
|
313
|
+
expect.stringContaining("pg_advisory_xact_lock"),
|
|
314
|
+
expect.stringContaining("lattice_projects"),
|
|
315
|
+
expect.stringContaining("pg_advisory_xact_lock"),
|
|
316
|
+
expect.stringContaining("SELECT 1 FROM lattice_projects"),
|
|
317
|
+
expect.stringContaining("DELETE FROM lattice_capability_bundles"),
|
|
318
|
+
"COMMIT",
|
|
319
|
+
]);
|
|
320
|
+
expect(String(clientQuery.mock.calls[4][0])).toContain("FOR UPDATE");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("rejects a revision that differs below JavaScript millisecond precision", async () => {
|
|
324
|
+
const clientQuery = jest.fn()
|
|
325
|
+
.mockResolvedValueOnce({})
|
|
326
|
+
.mockResolvedValueOnce({})
|
|
327
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
328
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
329
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
330
|
+
.mockResolvedValueOnce({ rows: [{ id: row.id, updated_at: "2026-08-27 12:00:00.123457+00" }] })
|
|
331
|
+
.mockResolvedValueOnce({});
|
|
332
|
+
const release = jest.fn();
|
|
333
|
+
const store = new PostgreSQLProjectStore({
|
|
334
|
+
pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await expect(store.updateCapabilityBundleIds(
|
|
338
|
+
"tenant-1",
|
|
339
|
+
"project-1",
|
|
340
|
+
[row.id],
|
|
341
|
+
{ [row.id]: "2026-08-27 12:00:00.123456+00" },
|
|
342
|
+
)).resolves.toEqual({ status: "bundle_conflict" });
|
|
343
|
+
expect(clientQuery).toHaveBeenCalledWith("ROLLBACK");
|
|
344
|
+
expect(clientQuery.mock.calls.some((call) => String(call[0]).includes("UPDATE lattice_projects"))).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it.each([
|
|
348
|
+
[[], "project_not_found"],
|
|
349
|
+
[[{ id: "project-1" }], "bundle_not_found"],
|
|
350
|
+
])("distinguishes an atomic update miss as %s", async (projectRows, status) => {
|
|
351
|
+
const clientQuery = jest.fn()
|
|
352
|
+
.mockResolvedValueOnce({})
|
|
353
|
+
.mockResolvedValueOnce({})
|
|
354
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
355
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
356
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
357
|
+
.mockResolvedValueOnce({ rows: [] })
|
|
358
|
+
.mockResolvedValueOnce({ rows: projectRows })
|
|
359
|
+
.mockResolvedValueOnce({});
|
|
360
|
+
const release = jest.fn();
|
|
361
|
+
const store = new PostgreSQLProjectStore({
|
|
362
|
+
pool: { connect: jest.fn().mockResolvedValue({ query: clientQuery, release }) } as unknown as Pool,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await expect(store.updateCapabilityBundleIds("tenant-1", "project-1", [row.id]))
|
|
366
|
+
.resolves.toEqual({ status });
|
|
367
|
+
expect(String(clientQuery.mock.calls[5][0])).toContain("lattice_projects");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("uses a tenant-scoped JSONB array membership query", async () => {
|
|
371
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ exists: 1 }] });
|
|
372
|
+
const store = new PostgreSQLProjectStore({ pool: { query: mockQuery } as unknown as Pool });
|
|
373
|
+
await expect(store.isCapabilityBundleReferenced("tenant-1", "bundle-1")).resolves.toBe(true);
|
|
374
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
375
|
+
expect(sql).toContain("tenant_id = $1");
|
|
376
|
+
expect(sql).toContain("jsonb_typeof(config->'capabilityBundleIds') = 'array'");
|
|
377
|
+
expect(sql).toContain("COALESCE(config->'capabilityBundleIds', '[]'::jsonb) ? $2");
|
|
378
|
+
expect(params).toEqual(["tenant-1", "bundle-1"]);
|
|
379
|
+
|
|
380
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
381
|
+
await expect(store.isCapabilityBundleReferenced("tenant-2", "bundle-1")).resolves.toBe(false);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -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,
|