@axiom-lattice/pg-stores 2.0.10 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +60 -0
- package/dist/index.d.mts +52 -4
- package/dist/index.d.ts +52 -4
- package/dist/index.js +907 -123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +905 -123
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLA2AApiKeyStore.migrations.test.ts +76 -0
- package/src/__tests__/PostgreSQLA2AApiKeyStore.test.ts +133 -0
- package/src/__tests__/PostgreSQLAgentWebAppStore.migrations.test.ts +77 -0
- package/src/__tests__/PostgreSQLAgentWebAppStore.test.ts +290 -0
- package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +37 -2
- package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +90 -0
- package/src/__tests__/PostgreSQLTaskStore.test.ts +287 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +104 -0
- package/src/__tests__/add_a2a_key_assistant_ids.test.ts +34 -0
- package/src/__tests__/workspace-project-store.test.ts +48 -0
- package/src/createPgStoreConfig.ts +10 -1
- package/src/index.ts +10 -0
- package/src/migrations/add_a2a_key_assistant_ids.ts +24 -0
- package/src/migrations/add_project_kind_column.ts +29 -0
- package/src/migrations/agent_web_apps_migration.ts +37 -0
- package/src/migrations/task_work_items_migration.ts +15 -0
- package/src/stores/PostgreSQLA2AApiKeyStore.ts +23 -8
- package/src/stores/PostgreSQLAgentWebAppStore.ts +280 -0
- package/src/stores/PostgreSQLProjectStore.ts +31 -8
- package/src/stores/PostgreSQLTaskStore.ts +272 -7
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +42 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/pg-stores",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
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": "
|
|
29
|
-
"@axiom-lattice/protocols": "
|
|
28
|
+
"@axiom-lattice/core": "4.0.1",
|
|
29
|
+
"@axiom-lattice/protocols": "4.0.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone-migration tests for PostgreSQLA2AApiKeyStore.
|
|
3
|
+
*
|
|
4
|
+
* When the store is constructed with its own `poolConfig` (no shared pool from
|
|
5
|
+
* `createPgStoreConfig`), its private MigrationManager must register every
|
|
6
|
+
* migration the store's queries depend on — including the `assistant_ids`
|
|
7
|
+
* column added after the original table creation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, beforeEach } from "@jest/globals";
|
|
11
|
+
|
|
12
|
+
const mockRecordedMigrationNames: string[] = [];
|
|
13
|
+
const mockClientSql: string[] = [];
|
|
14
|
+
|
|
15
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
16
|
+
encrypt: (value: string) => `enc:${value}`,
|
|
17
|
+
decrypt: (value: string) => value.replace(/^enc:/, ""),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
jest.mock("pg", () => {
|
|
21
|
+
const client = {
|
|
22
|
+
query: async (sql: string, params?: unknown[]) => {
|
|
23
|
+
mockClientSql.push(sql);
|
|
24
|
+
if (sql.includes("information_schema.tables")) {
|
|
25
|
+
return { rows: [{ exists: true }] };
|
|
26
|
+
}
|
|
27
|
+
if (sql.includes("pg_index")) {
|
|
28
|
+
// PK already on `name` — no legacy schema migration needed.
|
|
29
|
+
return { rows: [] };
|
|
30
|
+
}
|
|
31
|
+
if (sql.includes("INSERT INTO lattice_schema_migrations")) {
|
|
32
|
+
mockRecordedMigrationNames.push(params?.[0] as string);
|
|
33
|
+
return { rows: [] };
|
|
34
|
+
}
|
|
35
|
+
if (sql.includes("FROM lattice_schema_migrations")) {
|
|
36
|
+
return { rows: [] };
|
|
37
|
+
}
|
|
38
|
+
return { rows: [] };
|
|
39
|
+
},
|
|
40
|
+
release: () => undefined,
|
|
41
|
+
};
|
|
42
|
+
const pool = {
|
|
43
|
+
connect: async () => client,
|
|
44
|
+
query: async () => ({ rows: [] }),
|
|
45
|
+
end: async () => undefined,
|
|
46
|
+
};
|
|
47
|
+
return { Pool: function Pool() { return pool; } };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
import { PostgreSQLA2AApiKeyStore } from "../stores/PostgreSQLA2AApiKeyStore";
|
|
51
|
+
|
|
52
|
+
describe("PostgreSQLA2AApiKeyStore standalone migrations", () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
mockRecordedMigrationNames.length = 0;
|
|
55
|
+
mockClientSql.length = 0;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("registers and applies both the table and assistant_ids migrations", async () => {
|
|
59
|
+
const store = new PostgreSQLA2AApiKeyStore({
|
|
60
|
+
poolConfig: "postgres://unused",
|
|
61
|
+
autoMigrate: false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await store.initialize();
|
|
65
|
+
|
|
66
|
+
expect(mockRecordedMigrationNames).toEqual([
|
|
67
|
+
"create_a2a_api_keys_table",
|
|
68
|
+
"add_a2a_key_assistant_ids",
|
|
69
|
+
]);
|
|
70
|
+
expect(
|
|
71
|
+
mockClientSql.some((sql) => sql.includes("ADD COLUMN IF NOT EXISTS assistant_ids")),
|
|
72
|
+
).toBe(true);
|
|
73
|
+
|
|
74
|
+
await store.dispose();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQLA2AApiKeyStore behavior tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies the store aligns with the new A2A key scope model:
|
|
5
|
+
* - projectId is required (written directly, no null fallback)
|
|
6
|
+
* - assistantIds is persisted as JSONB and projected back
|
|
7
|
+
* - workspaceId is no longer read or written
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
|
11
|
+
import type { Pool } from "pg";
|
|
12
|
+
|
|
13
|
+
const mockQuery = jest.fn();
|
|
14
|
+
|
|
15
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
16
|
+
encrypt: (value: string) => `enc:${value}`,
|
|
17
|
+
decrypt: (value: string) => value.replace(/^enc:/, ""),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { PostgreSQLA2AApiKeyStore } from "../stores/PostgreSQLA2AApiKeyStore";
|
|
21
|
+
|
|
22
|
+
describe("PostgreSQLA2AApiKeyStore", () => {
|
|
23
|
+
const pool = { query: mockQuery } as unknown as Pool;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("create writes assistant_ids and project_id, omits workspace_id", async () => {
|
|
30
|
+
const store = new PostgreSQLA2AApiKeyStore({ pool });
|
|
31
|
+
mockQuery.mockResolvedValueOnce({
|
|
32
|
+
rows: [{
|
|
33
|
+
id: "k1",
|
|
34
|
+
key_value: "enc:a2a_x",
|
|
35
|
+
tenant_id: "t1",
|
|
36
|
+
project_id: "p1",
|
|
37
|
+
assistant_ids: ["a1", "a2"],
|
|
38
|
+
label: "lbl",
|
|
39
|
+
enabled: true,
|
|
40
|
+
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
41
|
+
updated_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
42
|
+
}],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const record = await store.create({
|
|
46
|
+
tenantId: "t1",
|
|
47
|
+
projectId: "p1",
|
|
48
|
+
assistantIds: ["a1", "a2"],
|
|
49
|
+
label: "lbl",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
53
|
+
expect(sql).toContain("assistant_ids");
|
|
54
|
+
expect(sql).not.toContain("workspace_id");
|
|
55
|
+
expect(params[2]).toBe("p1");
|
|
56
|
+
expect(params[3]).toBe(JSON.stringify(["a1", "a2"]));
|
|
57
|
+
expect(record.projectId).toBe("p1");
|
|
58
|
+
expect(record.assistantIds).toEqual(["a1", "a2"]);
|
|
59
|
+
expect("workspaceId" in record).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("create stores null assistant_ids when not provided", async () => {
|
|
63
|
+
const store = new PostgreSQLA2AApiKeyStore({ pool });
|
|
64
|
+
mockQuery.mockResolvedValueOnce({
|
|
65
|
+
rows: [{
|
|
66
|
+
id: "k2",
|
|
67
|
+
key_value: "enc:a2a_y",
|
|
68
|
+
tenant_id: "t1",
|
|
69
|
+
project_id: "p1",
|
|
70
|
+
assistant_ids: null,
|
|
71
|
+
label: null,
|
|
72
|
+
enabled: true,
|
|
73
|
+
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
74
|
+
updated_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
75
|
+
}],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const record = await store.create({ tenantId: "t1", projectId: "p1" });
|
|
79
|
+
|
|
80
|
+
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
81
|
+
expect(params[3]).toBe(null);
|
|
82
|
+
expect(record.assistantIds).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("findById returns the matching key record", async () => {
|
|
86
|
+
const store = new PostgreSQLA2AApiKeyStore({ pool });
|
|
87
|
+
mockQuery.mockResolvedValueOnce({
|
|
88
|
+
rows: [{
|
|
89
|
+
id: "k1",
|
|
90
|
+
key_value: "enc:a2a_x",
|
|
91
|
+
tenant_id: "t1",
|
|
92
|
+
project_id: "p1",
|
|
93
|
+
assistant_ids: null,
|
|
94
|
+
label: null,
|
|
95
|
+
enabled: true,
|
|
96
|
+
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
97
|
+
updated_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
98
|
+
}],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const record = await store.findById("k1");
|
|
102
|
+
|
|
103
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
104
|
+
expect.stringContaining("WHERE id = $1"),
|
|
105
|
+
["k1"],
|
|
106
|
+
);
|
|
107
|
+
expect(record?.tenantId).toBe("t1");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("loadIntoMap projects assistantIds and omits workspaceId", async () => {
|
|
111
|
+
const store = new PostgreSQLA2AApiKeyStore({ pool });
|
|
112
|
+
mockQuery.mockResolvedValueOnce({
|
|
113
|
+
rows: [{
|
|
114
|
+
id: "k1",
|
|
115
|
+
key_value: "enc:a2a_x",
|
|
116
|
+
tenant_id: "t1",
|
|
117
|
+
project_id: "p1",
|
|
118
|
+
assistant_ids: ["a1"],
|
|
119
|
+
label: null,
|
|
120
|
+
enabled: true,
|
|
121
|
+
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
122
|
+
updated_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
123
|
+
}],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const map = await store.loadIntoMap();
|
|
127
|
+
const entry = map.get("a2a_x");
|
|
128
|
+
expect(entry).toBeDefined();
|
|
129
|
+
expect(entry?.projectId).toBe("p1");
|
|
130
|
+
expect(entry?.assistantIds).toEqual(["a1"]);
|
|
131
|
+
expect("workspaceId" in (entry ?? {})).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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_agent_web_apps")) {
|
|
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 { PostgreSQLAgentWebAppStore } from "../stores/PostgreSQLAgentWebAppStore";
|
|
32
|
+
|
|
33
|
+
describe("PostgreSQLAgentWebAppStore standalone migrations", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
recordedMigrationNames.length = 0;
|
|
36
|
+
clientSql.length = 0;
|
|
37
|
+
migrationError = null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("registers and applies the agent web apps table migration", async () => {
|
|
41
|
+
const store = new PostgreSQLAgentWebAppStore({
|
|
42
|
+
poolConfig: "postgres://unused",
|
|
43
|
+
autoMigrate: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await store.initialize();
|
|
47
|
+
|
|
48
|
+
expect(recordedMigrationNames).toEqual(["create_agent_web_apps_table"]);
|
|
49
|
+
expect(clientSql.some((sql) => sql.includes("CREATE TABLE IF NOT EXISTS lattice_agent_web_apps"))).toBe(true);
|
|
50
|
+
expect(clientSql.some((sql) => sql.includes("status VARCHAR(32) NOT NULL DEFAULT 'draft'"))).toBe(true);
|
|
51
|
+
expect(clientSql.some((sql) => sql.includes(
|
|
52
|
+
"CONSTRAINT chk_lattice_agent_web_apps_status CHECK (status IN ('draft', 'active', 'disabled'))",
|
|
53
|
+
))).toBe(true);
|
|
54
|
+
expect(clientSql.some((sql) => sql.includes(
|
|
55
|
+
"created_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', NOW())",
|
|
56
|
+
))).toBe(true);
|
|
57
|
+
expect(clientSql.some((sql) => sql.includes(
|
|
58
|
+
"updated_at TIMESTAMPTZ NOT NULL DEFAULT date_trunc('milliseconds', NOW())",
|
|
59
|
+
))).toBe(true);
|
|
60
|
+
expect(clientSql.some((sql) => sql.includes("idx_lattice_agent_web_apps_tenant_assistant"))).toBe(true);
|
|
61
|
+
|
|
62
|
+
await store.dispose();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("surfaces an eager auto-migration rejection through initialize without retrying", async () => {
|
|
66
|
+
migrationError = new Error("migration failed");
|
|
67
|
+
const store = new PostgreSQLAgentWebAppStore({ poolConfig: "postgres://unused" });
|
|
68
|
+
|
|
69
|
+
await expect(store.initialize()).rejects.toThrow("migration failed");
|
|
70
|
+
await expect(store.list("tenant-1")).rejects.toThrow("migration failed");
|
|
71
|
+
|
|
72
|
+
expect(clientSql.filter((sql) =>
|
|
73
|
+
sql.includes("CREATE TABLE IF NOT EXISTS lattice_agent_web_apps")
|
|
74
|
+
)).toHaveLength(1);
|
|
75
|
+
await store.dispose();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
import type {
|
|
3
|
+
AgentWebAppStorePatch,
|
|
4
|
+
CreateAgentWebAppInput,
|
|
5
|
+
UpdateAgentWebAppInput,
|
|
6
|
+
} from "@axiom-lattice/protocols";
|
|
7
|
+
import type { Pool } from "pg";
|
|
8
|
+
|
|
9
|
+
import { PostgreSQLAgentWebAppStore } from "../stores/PostgreSQLAgentWebAppStore";
|
|
10
|
+
|
|
11
|
+
const mockQuery = jest.fn();
|
|
12
|
+
const now = new Date("2026-08-24T12:00:00.000Z");
|
|
13
|
+
|
|
14
|
+
const input: CreateAgentWebAppInput = {
|
|
15
|
+
assistantId: "assistant-1",
|
|
16
|
+
name: "Support",
|
|
17
|
+
description: "Customer support",
|
|
18
|
+
integration: { type: "react_sdk" },
|
|
19
|
+
scope: {
|
|
20
|
+
defaultProjectId: "project-1",
|
|
21
|
+
allowedProjectIds: ["project-1", "project-2"],
|
|
22
|
+
defaultModelKey: "model-1",
|
|
23
|
+
allowedModelKeys: ["model-1"],
|
|
24
|
+
},
|
|
25
|
+
features: {
|
|
26
|
+
projectSelector: true,
|
|
27
|
+
modelSelector: true,
|
|
28
|
+
threadManagement: true,
|
|
29
|
+
attachments: false,
|
|
30
|
+
hitl: true,
|
|
31
|
+
genUI: false,
|
|
32
|
+
},
|
|
33
|
+
appearance: { title: "Help", primaryColor: "#123456" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const row = {
|
|
37
|
+
id: "webapp_123",
|
|
38
|
+
tenant_id: "tenant-1",
|
|
39
|
+
assistant_id: "assistant-1",
|
|
40
|
+
name: "Support",
|
|
41
|
+
description: "Customer support",
|
|
42
|
+
status: "draft",
|
|
43
|
+
integration: input.integration,
|
|
44
|
+
scope: input.scope,
|
|
45
|
+
features: input.features,
|
|
46
|
+
appearance: input.appearance,
|
|
47
|
+
created_at: now,
|
|
48
|
+
updated_at: now,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("PostgreSQLAgentWebAppStore", () => {
|
|
52
|
+
const pool = { query: mockQuery } as unknown as Pool;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("creates a draft web app with a generated webapp id and JSONB values", async () => {
|
|
59
|
+
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
60
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
61
|
+
|
|
62
|
+
const result = await store.create("tenant-1", input);
|
|
63
|
+
|
|
64
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
65
|
+
expect(sql).toContain("INSERT INTO lattice_agent_web_apps");
|
|
66
|
+
expect(sql).toContain("status");
|
|
67
|
+
expect(params[0]).toEqual(expect.stringMatching(/^webapp_[0-9a-f]{32}$/));
|
|
68
|
+
expect(params).toEqual([
|
|
69
|
+
expect.stringMatching(/^webapp_[0-9a-f]{32}$/),
|
|
70
|
+
"tenant-1",
|
|
71
|
+
"assistant-1",
|
|
72
|
+
"Support",
|
|
73
|
+
"Customer support",
|
|
74
|
+
"draft",
|
|
75
|
+
JSON.stringify(input.integration),
|
|
76
|
+
JSON.stringify(input.scope),
|
|
77
|
+
JSON.stringify(input.features),
|
|
78
|
+
JSON.stringify(input.appearance),
|
|
79
|
+
]);
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
id: "webapp_123",
|
|
82
|
+
tenantId: "tenant-1",
|
|
83
|
+
assistantId: "assistant-1",
|
|
84
|
+
name: "Support",
|
|
85
|
+
description: "Customer support",
|
|
86
|
+
status: "draft",
|
|
87
|
+
integration: input.integration,
|
|
88
|
+
scope: input.scope,
|
|
89
|
+
features: input.features,
|
|
90
|
+
appearance: input.appearance,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("lists tenant web apps in deterministic newest-first order", async () => {
|
|
97
|
+
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
98
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
99
|
+
|
|
100
|
+
await store.list("tenant-1");
|
|
101
|
+
|
|
102
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
103
|
+
expect.stringMatching(/WHERE tenant_id = \$1\s+ORDER BY created_at DESC, id DESC/),
|
|
104
|
+
["tenant-1"],
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("optionally filters list by assistant", async () => {
|
|
109
|
+
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
110
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
111
|
+
|
|
112
|
+
await store.list("tenant-1", "assistant-1");
|
|
113
|
+
|
|
114
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
115
|
+
expect.stringMatching(/WHERE tenant_id = \$1 AND assistant_id = \$2/),
|
|
116
|
+
["tenant-1", "assistant-1"],
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("gets by tenant and id and returns null when absent", async () => {
|
|
121
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
122
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
123
|
+
|
|
124
|
+
await expect(store.getById("tenant-1", "webapp_missing")).resolves.toBeNull();
|
|
125
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
126
|
+
expect.stringMatching(/WHERE tenant_id = \$1 AND id = \$2/),
|
|
127
|
+
["tenant-1", "webapp_missing"],
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("finds a globally identifiable publication by primary key", async () => {
|
|
132
|
+
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
133
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
134
|
+
|
|
135
|
+
await expect(store.findById("webapp_123")).resolves.toMatchObject({
|
|
136
|
+
id: "webapp_123",
|
|
137
|
+
tenantId: "tenant-1",
|
|
138
|
+
});
|
|
139
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
140
|
+
expect.stringMatching(/WHERE id = \$1/),
|
|
141
|
+
["webapp_123"],
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("updates only known mutable fields and scopes by tenant and id", async () => {
|
|
146
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ ...row, name: "Renamed", description: null }] });
|
|
147
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
148
|
+
const patch = {
|
|
149
|
+
name: "Renamed",
|
|
150
|
+
description: undefined,
|
|
151
|
+
assistantId: "assistant-evil",
|
|
152
|
+
} as unknown as UpdateAgentWebAppInput;
|
|
153
|
+
|
|
154
|
+
const result = await store.update("tenant-1", "webapp_123", patch);
|
|
155
|
+
|
|
156
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
157
|
+
expect(sql).toContain("name = $1");
|
|
158
|
+
expect(sql).toContain("description = $2");
|
|
159
|
+
expect(sql).toContain("updated_at = GREATEST(");
|
|
160
|
+
expect(sql).toContain("WHERE tenant_id = $3 AND id = $4");
|
|
161
|
+
expect(sql).not.toContain("assistant_id =");
|
|
162
|
+
expect(params).toEqual(["Renamed", null, "tenant-1", "webapp_123"]);
|
|
163
|
+
expect(result?.description).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("updates a defined status and returns the persisted row", async () => {
|
|
167
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ ...row, status: "active" }] });
|
|
168
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
169
|
+
const patch: AgentWebAppStorePatch = { status: "active" };
|
|
170
|
+
|
|
171
|
+
const result = await store.update("tenant-1", "webapp_123", patch);
|
|
172
|
+
|
|
173
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
174
|
+
expect.stringMatching(
|
|
175
|
+
/SET status = \$1, updated_at = GREATEST\(\s*date_trunc\('milliseconds', clock_timestamp\(\)\),\s*updated_at \+ interval '1 millisecond'\s*\)\s+WHERE tenant_id = \$2 AND id = \$3/,
|
|
176
|
+
),
|
|
177
|
+
["active", "tenant-1", "webapp_123"],
|
|
178
|
+
);
|
|
179
|
+
expect(result?.status).toBe("active");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("monotonically advances a same-millisecond version so a stale writer conflicts", async () => {
|
|
183
|
+
const advanced = new Date(now.getTime() + 1);
|
|
184
|
+
mockQuery
|
|
185
|
+
.mockResolvedValueOnce({ rowCount: 1, rows: [{ ...row, status: "active", updated_at: advanced }] })
|
|
186
|
+
.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
|
187
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
188
|
+
|
|
189
|
+
await expect(store.update(
|
|
190
|
+
"tenant-1",
|
|
191
|
+
"webapp_123",
|
|
192
|
+
{ status: "active" },
|
|
193
|
+
{ expectedUpdatedAt: now },
|
|
194
|
+
)).resolves.toMatchObject({ status: "active", updatedAt: advanced });
|
|
195
|
+
await expect(store.update(
|
|
196
|
+
"tenant-1",
|
|
197
|
+
"webapp_123",
|
|
198
|
+
{ status: "disabled" },
|
|
199
|
+
{ expectedUpdatedAt: now },
|
|
200
|
+
)).resolves.toBeNull();
|
|
201
|
+
|
|
202
|
+
const monotonicCasSql = expect.stringMatching(
|
|
203
|
+
/SET status = \$1, updated_at = GREATEST\(\s*date_trunc\('milliseconds', clock_timestamp\(\)\),\s*updated_at \+ interval '1 millisecond'\s*\)\s+WHERE tenant_id = \$2 AND id = \$3 AND date_trunc\('milliseconds', updated_at\) = \$4/,
|
|
204
|
+
);
|
|
205
|
+
expect(mockQuery).toHaveBeenNthCalledWith(
|
|
206
|
+
1,
|
|
207
|
+
monotonicCasSql,
|
|
208
|
+
["active", "tenant-1", "webapp_123", now],
|
|
209
|
+
);
|
|
210
|
+
expect(mockQuery).toHaveBeenNthCalledWith(
|
|
211
|
+
2,
|
|
212
|
+
monotonicCasSql,
|
|
213
|
+
["disabled", "tenant-1", "webapp_123", now],
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("serializes JSONB update fields", async () => {
|
|
218
|
+
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
219
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
220
|
+
const patch: UpdateAgentWebAppInput = {
|
|
221
|
+
scope: input.scope,
|
|
222
|
+
features: input.features,
|
|
223
|
+
appearance: input.appearance,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
await store.update("tenant-1", "webapp_123", patch);
|
|
227
|
+
|
|
228
|
+
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
229
|
+
expect(params).toEqual([
|
|
230
|
+
JSON.stringify(input.scope),
|
|
231
|
+
JSON.stringify(input.features),
|
|
232
|
+
JSON.stringify(input.appearance),
|
|
233
|
+
"tenant-1",
|
|
234
|
+
"webapp_123",
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("ignores undefined non-description fields as a read-only no-op", async () => {
|
|
239
|
+
mockQuery.mockResolvedValueOnce({ rows: [row] });
|
|
240
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
241
|
+
const patch: AgentWebAppStorePatch = {
|
|
242
|
+
name: undefined,
|
|
243
|
+
scope: undefined,
|
|
244
|
+
features: undefined,
|
|
245
|
+
appearance: undefined,
|
|
246
|
+
status: undefined,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const result = await store.update("tenant-1", "webapp_123", patch);
|
|
250
|
+
|
|
251
|
+
expect(mockQuery).toHaveBeenCalledTimes(1);
|
|
252
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
253
|
+
expect.stringMatching(/^SELECT[\s\S]+WHERE tenant_id = \$1 AND id = \$2$/),
|
|
254
|
+
["tenant-1", "webapp_123"],
|
|
255
|
+
);
|
|
256
|
+
expect(result?.updatedAt).toBe(now);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("clears description when it is explicitly present as undefined", async () => {
|
|
260
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ ...row, description: null }] });
|
|
261
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
262
|
+
|
|
263
|
+
await store.update("tenant-1", "webapp_123", { description: undefined });
|
|
264
|
+
|
|
265
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
266
|
+
expect.stringContaining("SET description = $1, updated_at = GREATEST("),
|
|
267
|
+
[null, "tenant-1", "webapp_123"],
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("deletes by tenant and id and reports whether a row was removed", async () => {
|
|
272
|
+
mockQuery.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
|
273
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
274
|
+
|
|
275
|
+
await expect(store.delete("tenant-1", "webapp_123")).resolves.toBe(true);
|
|
276
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
277
|
+
expect.stringMatching(/DELETE FROM lattice_agent_web_apps\s+WHERE tenant_id = \$1 AND id = \$2/),
|
|
278
|
+
["tenant-1", "webapp_123"],
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("rejects rows that violate the stored JSON contract", async () => {
|
|
283
|
+
mockQuery.mockResolvedValueOnce({ rows: [{ ...row, integration: { type: "iframe" } }] });
|
|
284
|
+
const store = new PostgreSQLAgentWebAppStore({ pool });
|
|
285
|
+
|
|
286
|
+
await expect(store.getById("tenant-1", "webapp_123")).rejects.toThrow(
|
|
287
|
+
"Invalid agent web app row",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PostgreSQLChannelInstallationStore } from "../stores/PostgreSQLChannelInstallationStore";
|
|
2
|
+
import type { Pool } from "pg";
|
|
2
3
|
|
|
3
4
|
const mockQuery = jest.fn();
|
|
4
5
|
|
|
@@ -20,8 +21,7 @@ describe("PostgreSQLChannelInstallationStore", () => {
|
|
|
20
21
|
|
|
21
22
|
it("retrieves and decrypts an installation by id", async () => {
|
|
22
23
|
const store = new PostgreSQLChannelInstallationStore({
|
|
23
|
-
|
|
24
|
-
autoMigrate: false,
|
|
24
|
+
pool: { query: mockQuery } as unknown as Pool,
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
mockQuery.mockResolvedValueOnce({
|
|
@@ -51,4 +51,39 @@ describe("PostgreSQLChannelInstallationStore", () => {
|
|
|
51
51
|
expect(installation?.config.appSecret).toBe("secret");
|
|
52
52
|
expect(installation?.config.verificationToken).toBe("token-1");
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
it("rejects a duplicate id without changing the original installation", async () => {
|
|
56
|
+
const store = new PostgreSQLChannelInstallationStore({
|
|
57
|
+
pool: { query: mockQuery } as unknown as Pool,
|
|
58
|
+
});
|
|
59
|
+
const originalRow = {
|
|
60
|
+
id: "install-1",
|
|
61
|
+
tenant_id: "tenant-a",
|
|
62
|
+
channel: "lark",
|
|
63
|
+
name: "Original",
|
|
64
|
+
config: { appId: "app-1", appSecret: "enc:secret-1" },
|
|
65
|
+
enabled: true,
|
|
66
|
+
fallback_agent_id: null,
|
|
67
|
+
reject_when_no_binding: true,
|
|
68
|
+
created_at: new Date("2026-04-10T00:00:00.000Z"),
|
|
69
|
+
updated_at: new Date("2026-04-10T00:00:00.000Z"),
|
|
70
|
+
};
|
|
71
|
+
const duplicateError = Object.assign(new Error("duplicate key value violates unique constraint"), {
|
|
72
|
+
code: "23505",
|
|
73
|
+
});
|
|
74
|
+
mockQuery.mockRejectedValueOnce(duplicateError).mockResolvedValueOnce({ rows: [originalRow] });
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
store.createInstallation("tenant-a", "install-1", {
|
|
78
|
+
channel: "lark",
|
|
79
|
+
name: "Replacement",
|
|
80
|
+
config: { appId: "app-2", appSecret: "secret-2" },
|
|
81
|
+
}),
|
|
82
|
+
).rejects.toMatchObject({ code: "23505" });
|
|
83
|
+
await expect(store.getInstallationById("install-1")).resolves.toMatchObject({
|
|
84
|
+
id: "install-1",
|
|
85
|
+
name: "Original",
|
|
86
|
+
config: { appId: "app-1", appSecret: "secret-1" },
|
|
87
|
+
});
|
|
88
|
+
});
|
|
54
89
|
});
|