@axiom-lattice/pg-stores 3.1.6 → 3.1.9
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 +29 -0
- package/dist/index.d.mts +54 -2
- package/dist/index.d.ts +54 -2
- package/dist/index.js +424 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +421 -47
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLA2AApiKeyStore.migrations.test.ts +2 -1
- package/src/__tests__/PostgreSQLA2AApiKeyStore.test.ts +45 -0
- package/src/__tests__/PostgreSQLMcpServerConfigStore.test.ts +79 -0
- package/src/__tests__/PostgreSQLOpenAuditStore.test.ts +100 -0
- package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +14 -0
- package/src/__tests__/PostgreSQLProjectRoomReadStateStore.test.ts +136 -0
- package/src/__tests__/PostgresSharedResourceStore.openGrants.test.ts +62 -0
- package/src/__tests__/create_open_audit_table.test.ts +40 -0
- package/src/createPgStoreConfig.ts +12 -1
- package/src/index.ts +2 -0
- package/src/migrations/add_api_key_grants.ts +25 -0
- package/src/migrations/add_share_open_grants.ts +23 -0
- package/src/migrations/create_open_audit_table.ts +36 -0
- package/src/migrations/project_room_migration.ts +26 -0
- package/src/stores/PostgreSQLA2AApiKeyStore.ts +90 -14
- package/src/stores/PostgreSQLMcpServerConfigStore.ts +23 -0
- package/src/stores/PostgreSQLOpenAuditStore.ts +143 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +36 -1
- package/src/stores/PostgreSQLProjectRoomReadStateStore.ts +47 -0
- package/src/stores/PostgresSharedResourceStore.ts +5 -2
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.9",
|
|
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.9",
|
|
29
|
+
"@axiom-lattice/protocols": "4.2.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -55,7 +55,7 @@ describe("PostgreSQLA2AApiKeyStore standalone migrations", () => {
|
|
|
55
55
|
mockClientSql.length = 0;
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it("registers and applies
|
|
58
|
+
it("registers and applies the table, assistant_ids, and grants migrations", async () => {
|
|
59
59
|
const store = new PostgreSQLA2AApiKeyStore({
|
|
60
60
|
poolConfig: "postgres://unused",
|
|
61
61
|
autoMigrate: false,
|
|
@@ -66,6 +66,7 @@ describe("PostgreSQLA2AApiKeyStore standalone migrations", () => {
|
|
|
66
66
|
expect(mockRecordedMigrationNames).toEqual([
|
|
67
67
|
"create_a2a_api_keys_table",
|
|
68
68
|
"add_a2a_key_assistant_ids",
|
|
69
|
+
"add_api_key_grants",
|
|
69
70
|
]);
|
|
70
71
|
expect(
|
|
71
72
|
mockClientSql.some((sql) => sql.includes("ADD COLUMN IF NOT EXISTS assistant_ids")),
|
|
@@ -131,3 +131,48 @@ describe("PostgreSQLA2AApiKeyStore", () => {
|
|
|
131
131
|
expect("workspaceId" in (entry ?? {})).toBe(false);
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("PostgreSQLA2AApiKeyStore grants column", () => {
|
|
136
|
+
const pool2 = { query: mockQuery } as unknown as Pool;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
jest.clearAllMocks();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("create persists grants as JSON", async () => {
|
|
143
|
+
const store = new PostgreSQLA2AApiKeyStore({ pool: pool2 });
|
|
144
|
+
const grants = [{ domain: "kb", items: ["search"] }];
|
|
145
|
+
mockQuery.mockResolvedValueOnce({
|
|
146
|
+
rows: [{
|
|
147
|
+
id: "k1", key_value: "enc:a2a_x", tenant_id: "t1", project_id: "p1",
|
|
148
|
+
assistant_ids: null, grants, label: null, enabled: true,
|
|
149
|
+
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
150
|
+
updated_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
151
|
+
}],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const record = await store.create({
|
|
155
|
+
tenantId: "t1", projectId: "p1", grants,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
159
|
+
expect(sql).toContain("grants");
|
|
160
|
+
expect(params[4]).toBe(JSON.stringify(grants));
|
|
161
|
+
expect(record.grants).toEqual(grants);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("mapRowToRecord projects grants back", async () => {
|
|
165
|
+
const store = new PostgreSQLA2AApiKeyStore({ pool: pool2 });
|
|
166
|
+
const grants = [{ domain: "kb" }];
|
|
167
|
+
mockQuery.mockResolvedValueOnce({
|
|
168
|
+
rows: [{
|
|
169
|
+
id: "k1", key_value: "enc:a2a_x", tenant_id: "t1", project_id: "p1",
|
|
170
|
+
assistant_ids: null, grants, label: null, enabled: true,
|
|
171
|
+
created_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
172
|
+
updated_at: new Date("2026-01-01T00:00:00.000Z"),
|
|
173
|
+
}],
|
|
174
|
+
});
|
|
175
|
+
const record = await store.findById("k1");
|
|
176
|
+
expect(record?.grants).toEqual(grants);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
// The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
|
|
5
|
+
// transitive deps), so mock it with the real encryption module — node:crypto only.
|
|
6
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
7
|
+
...jest.requireActual("../../../core/src/util/encryption"),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { PostgreSQLMcpServerConfigStore } from "../stores/PostgreSQLMcpServerConfigStore";
|
|
11
|
+
|
|
12
|
+
const makeRow = (configJson: string, isEnvEncrypted: boolean) => ({
|
|
13
|
+
id: "cfg-1",
|
|
14
|
+
tenant_id: "tenant-1",
|
|
15
|
+
key: "remote",
|
|
16
|
+
name: null,
|
|
17
|
+
description: null,
|
|
18
|
+
config: configJson,
|
|
19
|
+
selected_tools: [],
|
|
20
|
+
is_env_encrypted: isEnvEncrypted,
|
|
21
|
+
status: "disconnected",
|
|
22
|
+
created_at: new Date(),
|
|
23
|
+
updated_at: new Date(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("PostgreSQLMcpServerConfigStore header encryption", () => {
|
|
27
|
+
it("encrypts header values at rest and decrypts on read", async () => {
|
|
28
|
+
let storedConfig = "";
|
|
29
|
+
let storedFlag = false;
|
|
30
|
+
const query = jest.fn(async (sql: string, values: unknown[]) => {
|
|
31
|
+
if (sql.includes("INSERT INTO lattice_mcp_server_configs")) {
|
|
32
|
+
storedConfig = values[5] as string;
|
|
33
|
+
storedFlag = values[7] as boolean;
|
|
34
|
+
return { rowCount: 1 };
|
|
35
|
+
}
|
|
36
|
+
if (sql.includes("SELECT id, tenant_id, key")) {
|
|
37
|
+
return { rows: [makeRow(storedConfig, storedFlag)] };
|
|
38
|
+
}
|
|
39
|
+
return { rows: [] };
|
|
40
|
+
});
|
|
41
|
+
const store = new PostgreSQLMcpServerConfigStore({ pool: { query } as unknown as Pool });
|
|
42
|
+
|
|
43
|
+
await store.createConfig("tenant-1", "cfg-1", {
|
|
44
|
+
key: "remote",
|
|
45
|
+
config: {
|
|
46
|
+
name: "remote",
|
|
47
|
+
version: "1.0.0",
|
|
48
|
+
transport: "streamable_http",
|
|
49
|
+
url: "https://mcp.example.com/mcp",
|
|
50
|
+
headers: { Authorization: "Bearer sk-secret-token" },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(storedFlag).toBe(true);
|
|
55
|
+
const atRest = JSON.parse(storedConfig);
|
|
56
|
+
expect(atRest.headers.Authorization).not.toBe("Bearer sk-secret-token");
|
|
57
|
+
|
|
58
|
+
const read = await store.getConfigByKey("tenant-1", "remote");
|
|
59
|
+
expect(read?.config.headers?.Authorization).toBe("Bearer sk-secret-token");
|
|
60
|
+
expect(read?.config.headers?.Authorization).not.toBe(atRest.headers.Authorization);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("leaves legacy rows without headers untouched", async () => {
|
|
64
|
+
const legacyConfig = JSON.stringify({
|
|
65
|
+
name: "legacy", version: "1.0.0", transport: "stdio", command: "npx",
|
|
66
|
+
});
|
|
67
|
+
const query = jest.fn(async (sql: string) => {
|
|
68
|
+
if (sql.includes("SELECT id, tenant_id, key")) {
|
|
69
|
+
return { rows: [makeRow(legacyConfig, false)] };
|
|
70
|
+
}
|
|
71
|
+
return { rows: [] };
|
|
72
|
+
});
|
|
73
|
+
const store = new PostgreSQLMcpServerConfigStore({ pool: { query } as unknown as Pool });
|
|
74
|
+
|
|
75
|
+
const read = await store.getConfigByKey("tenant-1", "remote");
|
|
76
|
+
expect(read?.config.headers).toBeUndefined();
|
|
77
|
+
expect(read?.config.command).toBe("npx");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
const mockQuery = jest.fn();
|
|
5
|
+
import { PostgreSQLOpenAuditStore } from "../stores/PostgreSQLOpenAuditStore";
|
|
6
|
+
|
|
7
|
+
const pool = { query: mockQuery } as unknown as Pool;
|
|
8
|
+
|
|
9
|
+
describe("PostgreSQLOpenAuditStore", () => {
|
|
10
|
+
beforeEach(() => jest.clearAllMocks());
|
|
11
|
+
|
|
12
|
+
it("append inserts a row with JSON args", async () => {
|
|
13
|
+
const store = new PostgreSQLOpenAuditStore({ pool });
|
|
14
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
15
|
+
await store.append({
|
|
16
|
+
tenantId: "t1",
|
|
17
|
+
credentialId: "k1",
|
|
18
|
+
credentialKind: "api_key",
|
|
19
|
+
projectId: "p1",
|
|
20
|
+
domain: "kb",
|
|
21
|
+
action: "search",
|
|
22
|
+
args: { query: "x" },
|
|
23
|
+
status: "ok",
|
|
24
|
+
durationMs: 12.7,
|
|
25
|
+
});
|
|
26
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
27
|
+
expect(sql).toContain("INSERT INTO lattice_open_audit");
|
|
28
|
+
expect(params[6]).toBe(JSON.stringify({ query: "x" }));
|
|
29
|
+
expect(params[7]).toBe("ok");
|
|
30
|
+
expect(params[9]).toBe(13);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("append persists null args safely", async () => {
|
|
34
|
+
const store = new PostgreSQLOpenAuditStore({ pool });
|
|
35
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
36
|
+
await store.append({
|
|
37
|
+
tenantId: "t1",
|
|
38
|
+
credentialId: "k1",
|
|
39
|
+
credentialKind: "browser_token",
|
|
40
|
+
projectId: "p1",
|
|
41
|
+
domain: "kb",
|
|
42
|
+
action: "search",
|
|
43
|
+
args: undefined,
|
|
44
|
+
status: "error",
|
|
45
|
+
errorCode: "GRANT_DENIED",
|
|
46
|
+
durationMs: 1,
|
|
47
|
+
});
|
|
48
|
+
const params = mockQuery.mock.calls[0][1] as unknown[];
|
|
49
|
+
expect(params[6]).toBeNull();
|
|
50
|
+
expect(params[8]).toBe("GRANT_DENIED");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("PostgreSQLOpenAuditStore.query", () => {
|
|
55
|
+
beforeEach(() => jest.clearAllMocks());
|
|
56
|
+
|
|
57
|
+
it("builds a tenant-scoped filtered query", async () => {
|
|
58
|
+
const store = new PostgreSQLOpenAuditStore({ pool });
|
|
59
|
+
mockQuery.mockResolvedValueOnce({ rows: [] });
|
|
60
|
+
await store.query({
|
|
61
|
+
tenantId: "t1",
|
|
62
|
+
domain: "kb",
|
|
63
|
+
status: "error",
|
|
64
|
+
from: "2026-01-01T00:00:00Z",
|
|
65
|
+
limit: 10,
|
|
66
|
+
offset: 5,
|
|
67
|
+
});
|
|
68
|
+
const [sql, values] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
69
|
+
expect(sql).toContain("tenant_id = $1");
|
|
70
|
+
expect(sql).toContain("domain = $2");
|
|
71
|
+
expect(sql).toContain("status = $3");
|
|
72
|
+
expect(sql).toContain("created_at >= $4");
|
|
73
|
+
expect(sql).toContain("ORDER BY created_at DESC");
|
|
74
|
+
expect(values).toEqual(["t1", "kb", "error", "2026-01-01T00:00:00Z", 10, 5]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("maps rows back to records", async () => {
|
|
78
|
+
const store = new PostgreSQLOpenAuditStore({ pool });
|
|
79
|
+
mockQuery.mockResolvedValueOnce({
|
|
80
|
+
rows: [
|
|
81
|
+
{
|
|
82
|
+
id: "a1",
|
|
83
|
+
tenant_id: "t1",
|
|
84
|
+
credential_id: "k1",
|
|
85
|
+
credential_kind: "api_key",
|
|
86
|
+
project_id: "p1",
|
|
87
|
+
domain: "kb",
|
|
88
|
+
action: "search",
|
|
89
|
+
args: { q: "x" },
|
|
90
|
+
status: "ok",
|
|
91
|
+
error_code: null,
|
|
92
|
+
duration_ms: 7,
|
|
93
|
+
created_at: new Date("2026-01-01T00:00:00Z"),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
const rows = await store.query({ tenantId: "t1" });
|
|
98
|
+
expect(rows[0]).toMatchObject({ id: "a1", status: "ok", errorCode: undefined });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -100,4 +100,18 @@ describe("PostgreSQLProjectRoomMessageStore", () => {
|
|
|
100
100
|
await expect(new PostgreSQLProjectRoomMessageStore({ pool }).findById("tenant-1", "message-1"))
|
|
101
101
|
.rejects.toThrow("Invalid project room message row");
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
it("skips individually invalid rows when listing while preserving valid rows", async () => {
|
|
105
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
106
|
+
const malformed = { ...row, id: "message-bad", content: { text: "missing type" } };
|
|
107
|
+
query.mockResolvedValueOnce({ rows: [row, malformed] });
|
|
108
|
+
const store = new PostgreSQLProjectRoomMessageStore({ pool });
|
|
109
|
+
await expect(store.list({ tenantId: "tenant-1", roomId: "room-1", limit: 10 }))
|
|
110
|
+
.resolves.toEqual([expect.objectContaining({ id: "message-1" })]);
|
|
111
|
+
expect(warn).toHaveBeenCalledWith(
|
|
112
|
+
"[ProjectRoomMessageStore] Skipping invalid message row",
|
|
113
|
+
expect.objectContaining({ tenantId: "tenant-1", roomId: "room-1", rowId: "message-bad" }),
|
|
114
|
+
);
|
|
115
|
+
warn.mockRestore();
|
|
116
|
+
});
|
|
103
117
|
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
import type { ProjectRoomMessage } from "@axiom-lattice/protocols";
|
|
5
|
+
|
|
6
|
+
import { MigrationManager } from "../migrations/migration";
|
|
7
|
+
import { createProjectRoomReadStates, createProjectRoomTables } from "../migrations/project_room_migration";
|
|
8
|
+
import { PostgreSQLProjectRoomMessageStore } from "../stores/PostgreSQLProjectRoomMessageStore";
|
|
9
|
+
import { PostgreSQLProjectRoomReadStateStore } from "../stores/PostgreSQLProjectRoomReadStateStore";
|
|
10
|
+
|
|
11
|
+
const query = jest.fn();
|
|
12
|
+
const mockPool = { query } as unknown as Pool;
|
|
13
|
+
const readStateRow = {
|
|
14
|
+
tenant_id: "tenant-1", room_id: "room-1", user_id: "user-1",
|
|
15
|
+
last_read_at: new Date("2026-01-02T00:00:00.000Z"), updated_at: new Date("2026-01-02T00:00:01.000Z"),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("PostgreSQLProjectRoomReadStateStore SQL contract", () => {
|
|
19
|
+
beforeEach(() => query.mockReset());
|
|
20
|
+
|
|
21
|
+
it("returns null when no marker exists and selects by the full primary key", async () => {
|
|
22
|
+
query.mockResolvedValueOnce({ rows: [] });
|
|
23
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
24
|
+
await expect(store.get("tenant-1", "room-1", "user-1")).resolves.toBeNull();
|
|
25
|
+
expect(query.mock.calls[0][0]).toContain("FROM lattice_project_room_read_states");
|
|
26
|
+
expect(query.mock.calls[0][0]).toContain("WHERE tenant_id = $1 AND room_id = $2 AND user_id = $3");
|
|
27
|
+
expect(query.mock.calls[0][1]).toEqual(["tenant-1", "room-1", "user-1"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("upserts monotonically with GREATEST and returns the stored row", async () => {
|
|
31
|
+
query.mockResolvedValueOnce({ rows: [readStateRow] });
|
|
32
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
33
|
+
const lastReadAt = new Date("2026-01-02T00:00:00.000Z");
|
|
34
|
+
await expect(store.markRead({ tenantId: "tenant-1", roomId: "room-1", userId: "user-1", lastReadAt }))
|
|
35
|
+
.resolves.toEqual({
|
|
36
|
+
tenantId: "tenant-1", roomId: "room-1", userId: "user-1",
|
|
37
|
+
lastReadAt, updatedAt: new Date("2026-01-02T00:00:01.000Z"),
|
|
38
|
+
});
|
|
39
|
+
const [sql, params] = query.mock.calls[0] as [string, unknown[]];
|
|
40
|
+
expect(sql).toContain("ON CONFLICT (tenant_id, room_id, user_id) DO UPDATE");
|
|
41
|
+
expect(sql).toContain("GREATEST(lattice_project_room_read_states.last_read_at, EXCLUDED.last_read_at)");
|
|
42
|
+
expect(params).toEqual(["tenant-1", "room-1", "user-1", lastReadAt]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects an invalid markRead horizon before querying", async () => {
|
|
46
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
47
|
+
await expect(store.markRead({ tenantId: "tenant-1", roomId: "room-1", userId: "user-1", lastReadAt: new Date("bad") }))
|
|
48
|
+
.rejects.toThrow(RangeError);
|
|
49
|
+
expect(query).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects malformed read state rows", async () => {
|
|
53
|
+
query.mockResolvedValueOnce({ rows: [{ ...readStateRow, last_read_at: "2026-01-02" }] });
|
|
54
|
+
const store = new PostgreSQLProjectRoomReadStateStore({ pool: mockPool });
|
|
55
|
+
await expect(store.get("tenant-1", "room-1", "user-1")).rejects.toThrow("Invalid project room read state row");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("PostgreSQLProjectRoomMessageStore countAfter SQL contract", () => {
|
|
60
|
+
beforeEach(() => query.mockReset());
|
|
61
|
+
|
|
62
|
+
it("counts strictly after the horizon and excludes one human author", async () => {
|
|
63
|
+
query.mockResolvedValueOnce({ rows: [{ count: 2 }] });
|
|
64
|
+
const store = new PostgreSQLProjectRoomMessageStore({ pool: mockPool });
|
|
65
|
+
const after = new Date("2026-01-01T00:00:00.000Z");
|
|
66
|
+
await expect(store.countAfter({ tenantId: "tenant-1", roomId: "room-1", after, excludeAuthorUserId: "user-1" }))
|
|
67
|
+
.resolves.toBe(2);
|
|
68
|
+
const [sql, params] = query.mock.calls[0] as [string, unknown[]];
|
|
69
|
+
expect(sql).toContain("created_at > $3");
|
|
70
|
+
expect(sql).toContain("($4::text IS NULL OR author->>'type' <> 'human' OR author->>'userId' <> $4)");
|
|
71
|
+
expect(params).toEqual(["tenant-1", "room-1", after, "user-1"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("passes null when no author is excluded and rejects invalid horizons", async () => {
|
|
75
|
+
query.mockResolvedValueOnce({ rows: [{ count: 0 }] });
|
|
76
|
+
const store = new PostgreSQLProjectRoomMessageStore({ pool: mockPool });
|
|
77
|
+
const after = new Date("2026-01-01T00:00:00.000Z");
|
|
78
|
+
await expect(store.countAfter({ tenantId: "tenant-1", roomId: "room-1", after })).resolves.toBe(0);
|
|
79
|
+
expect(query.mock.calls[0][1]).toEqual(["tenant-1", "room-1", after, null]);
|
|
80
|
+
await expect(store.countAfter({ tenantId: "tenant-1", roomId: "room-1", after: new Date(Number.NaN) }))
|
|
81
|
+
.rejects.toThrow(RangeError);
|
|
82
|
+
expect(query).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
87
|
+
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
|
88
|
+
|
|
89
|
+
describeIfDatabase("PostgreSQLProjectRoomReadStateStore", () => {
|
|
90
|
+
let pool: Pool;
|
|
91
|
+
let store: PostgreSQLProjectRoomReadStateStore;
|
|
92
|
+
let messages: PostgreSQLProjectRoomMessageStore;
|
|
93
|
+
const tenantId = `read-state-${randomUUID()}`;
|
|
94
|
+
|
|
95
|
+
beforeAll(async () => {
|
|
96
|
+
pool = new Pool({ connectionString: databaseUrl });
|
|
97
|
+
const mm = new MigrationManager(pool);
|
|
98
|
+
mm.register(createProjectRoomTables);
|
|
99
|
+
mm.register(createProjectRoomReadStates);
|
|
100
|
+
await mm.migrate();
|
|
101
|
+
store = new PostgreSQLProjectRoomReadStateStore({ pool });
|
|
102
|
+
messages = new PostgreSQLProjectRoomMessageStore({ pool });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(async () => {
|
|
106
|
+
await pool.query("DELETE FROM lattice_project_room_messages WHERE tenant_id = $1", [tenantId]);
|
|
107
|
+
await pool.query("DELETE FROM lattice_project_room_read_states WHERE tenant_id = $1", [tenantId]);
|
|
108
|
+
await pool.end();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns null before any report and upserts monotonically", async () => {
|
|
112
|
+
await expect(store.get(tenantId, "room-1", "u1")).resolves.toBeNull();
|
|
113
|
+
await store.markRead({ tenantId, roomId: "room-1", userId: "u1", lastReadAt: new Date("2026-01-02T00:00:00Z") });
|
|
114
|
+
await store.markRead({ tenantId, roomId: "room-1", userId: "u1", lastReadAt: new Date("2026-01-01T00:00:00Z") });
|
|
115
|
+
const state = await store.get(tenantId, "room-1", "u1");
|
|
116
|
+
expect(state?.lastReadAt.toISOString()).toBe("2026-01-02T00:00:00.000Z");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("counts messages after a horizon excluding one human author", async () => {
|
|
120
|
+
const roomId = `room-${randomUUID()}`;
|
|
121
|
+
const base = (id: string, author: ProjectRoomMessage["author"]): Omit<ProjectRoomMessage, "createdAt"> => ({
|
|
122
|
+
id, tenantId, workspaceId: "w1", projectId: "p1", roomId, author,
|
|
123
|
+
content: { type: "text", text: id }, mentions: [], source: "user",
|
|
124
|
+
});
|
|
125
|
+
await messages.create({ ...base(`m0-${roomId}`, { type: "human", userId: "u2" }) });
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
127
|
+
await messages.create({ ...base(`m1-${roomId}`, { type: "human", userId: "u1" }) });
|
|
128
|
+
await messages.create({ ...base(`m2-${roomId}`, { type: "human", userId: "u2" }) });
|
|
129
|
+
await messages.create({ ...base(`m3-${roomId}`, { type: "bot", membershipId: "b1", assistantId: "a1" }) });
|
|
130
|
+
const first = await messages.findById(tenantId, `m0-${roomId}`);
|
|
131
|
+
const count = await messages.countAfter({
|
|
132
|
+
tenantId, roomId, after: first!.createdAt, excludeAuthorUserId: "u1",
|
|
133
|
+
});
|
|
134
|
+
expect(count).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import { PostgresSharedResourceStore } from "../stores/PostgresSharedResourceStore";
|
|
4
|
+
|
|
5
|
+
const mockQuery = jest.fn();
|
|
6
|
+
|
|
7
|
+
describe("PostgresSharedResourceStore openGrants", () => {
|
|
8
|
+
const pool = { query: mockQuery } as unknown as Pool;
|
|
9
|
+
beforeEach(() => jest.clearAllMocks());
|
|
10
|
+
|
|
11
|
+
it("persists open_grants as JSON on create", async () => {
|
|
12
|
+
const store = new PostgresSharedResourceStore({ pool } as never);
|
|
13
|
+
const openGrants = [{ domain: "kb", items: ["search"] }];
|
|
14
|
+
mockQuery.mockResolvedValueOnce({
|
|
15
|
+
rows: [
|
|
16
|
+
{
|
|
17
|
+
token: "tok",
|
|
18
|
+
address: JSON.stringify({ volume: "v", resourcePath: "index.html" }),
|
|
19
|
+
title: null,
|
|
20
|
+
created_by: "u1",
|
|
21
|
+
tenant_id: "t1",
|
|
22
|
+
workspace_id: "w1",
|
|
23
|
+
project_id: "p1",
|
|
24
|
+
assistant_id: null,
|
|
25
|
+
vm_isolation: "project",
|
|
26
|
+
visibility: "public",
|
|
27
|
+
password_hash: null,
|
|
28
|
+
expires_at: null,
|
|
29
|
+
max_access: null,
|
|
30
|
+
open_grants: openGrants,
|
|
31
|
+
access_count: 0,
|
|
32
|
+
revoked: false,
|
|
33
|
+
created_at: new Date().toISOString(),
|
|
34
|
+
version: 1,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const record = await store.create({
|
|
40
|
+
token: "tok",
|
|
41
|
+
address: { volume: "v", resourcePath: "index.html" },
|
|
42
|
+
title: null,
|
|
43
|
+
createdBy: "u1",
|
|
44
|
+
tenantId: "t1",
|
|
45
|
+
workspaceId: "w1",
|
|
46
|
+
projectId: "p1",
|
|
47
|
+
assistantId: null,
|
|
48
|
+
vmIsolation: "project",
|
|
49
|
+
visibility: "public",
|
|
50
|
+
passwordHash: null,
|
|
51
|
+
expiresAt: null,
|
|
52
|
+
maxAccess: null,
|
|
53
|
+
openGrants,
|
|
54
|
+
revoked: false,
|
|
55
|
+
} as never);
|
|
56
|
+
|
|
57
|
+
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
|
58
|
+
expect(sql).toContain("open_grants");
|
|
59
|
+
expect(params).toContain(JSON.stringify(openGrants));
|
|
60
|
+
expect(record.openGrants).toEqual(openGrants);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create_open_audit_table migration tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
6
|
+
import type { PoolClient } from "pg";
|
|
7
|
+
import { createOpenAuditTable } from "../migrations/create_open_audit_table";
|
|
8
|
+
|
|
9
|
+
describe("create_open_audit_table migration", () => {
|
|
10
|
+
it("has name create_open_audit_table", () => {
|
|
11
|
+
expect(createOpenAuditTable.name).toBe("create_open_audit_table");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("has version 179", () => {
|
|
15
|
+
expect(createOpenAuditTable.version).toBe(179);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("creates the audit table and tenant index in up()", async () => {
|
|
19
|
+
const queries: string[] = [];
|
|
20
|
+
const client = {
|
|
21
|
+
query: jest.fn(async (sql: string) => {
|
|
22
|
+
queries.push(sql);
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
await createOpenAuditTable.up(client as unknown as PoolClient);
|
|
26
|
+
expect(queries.some((q) => q.includes("CREATE TABLE IF NOT EXISTS lattice_open_audit"))).toBe(true);
|
|
27
|
+
expect(queries.some((q) => q.includes("idx_lattice_open_audit_tenant_created"))).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("drops the table in down()", async () => {
|
|
31
|
+
const queries: string[] = [];
|
|
32
|
+
const client = {
|
|
33
|
+
query: jest.fn(async (sql: string) => {
|
|
34
|
+
queries.push(sql);
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
await createOpenAuditTable.down!(client as unknown as PoolClient);
|
|
38
|
+
expect(queries.some((q) => q.includes("DROP TABLE IF EXISTS lattice_open_audit"))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -17,6 +17,7 @@ import { ThreadMessageQueueStore } from "./stores/ThreadMessageQueueStore";
|
|
|
17
17
|
import { ChannelBindingStore } from "./stores/ChannelBindingStore";
|
|
18
18
|
import { PostgreSQLChannelInstallationStore } from "./stores/PostgreSQLChannelInstallationStore";
|
|
19
19
|
import { PostgreSQLA2AApiKeyStore } from "./stores/PostgreSQLA2AApiKeyStore";
|
|
20
|
+
import { PostgreSQLOpenAuditStore } from "./stores/PostgreSQLOpenAuditStore";
|
|
20
21
|
import { PostgreSQLAgentWebAppStore } from "./stores/PostgreSQLAgentWebAppStore";
|
|
21
22
|
import { PostgreSQLScheduleStorage } from "./stores/PostgreSQLScheduleStorage";
|
|
22
23
|
import { PostgreSQLTaskStore } from "./stores/PostgreSQLTaskStore";
|
|
@@ -67,6 +68,9 @@ import { createWorkflowTrackingTables, addStepThreadId, addWorkflowRunsTenantSta
|
|
|
67
68
|
import { evalMigrations } from "./migrations/eval_migrations";
|
|
68
69
|
import { createA2AApiKeysTable } from "./migrations/a2a_api_key_migration";
|
|
69
70
|
import { addA2AKeyAssistantIds } from "./migrations/add_a2a_key_assistant_ids";
|
|
71
|
+
import { addApiKeyGrants } from "./migrations/add_api_key_grants";
|
|
72
|
+
import { createOpenAuditTable } from "./migrations/create_open_audit_table";
|
|
73
|
+
import { addShareOpenGrants } from "./migrations/add_share_open_grants";
|
|
70
74
|
import { taskMigrations } from "./migrations/task_migration";
|
|
71
75
|
import { createMenuItemsTable } from "./migrations/menu_items_migration";
|
|
72
76
|
import { addFileContentType } from "./migrations/menu_items_add_file_type";
|
|
@@ -82,12 +86,13 @@ import {
|
|
|
82
86
|
import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
|
|
83
87
|
import { addAgentWebAppsIdentity } from "./migrations/add_agent_web_apps_identity_migration";
|
|
84
88
|
import { createCapabilityBundlesTable } from "./migrations/capability_bundle_migration";
|
|
85
|
-
import { createProjectRoomTables } from "./migrations/project_room_migration";
|
|
89
|
+
import { createProjectRoomReadStates, createProjectRoomTables } from "./migrations/project_room_migration";
|
|
86
90
|
import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
|
|
87
91
|
import { PostgreSQLProjectRoomStore } from "./stores/PostgreSQLProjectRoomStore";
|
|
88
92
|
import { PostgreSQLProjectMembershipStore } from "./stores/PostgreSQLProjectMembershipStore";
|
|
89
93
|
import { PostgreSQLProjectBotMembershipStore } from "./stores/PostgreSQLProjectBotMembershipStore";
|
|
90
94
|
import { PostgreSQLProjectRoomMessageStore } from "./stores/PostgreSQLProjectRoomMessageStore";
|
|
95
|
+
import { PostgreSQLProjectRoomReadStateStore } from "./stores/PostgreSQLProjectRoomReadStateStore";
|
|
91
96
|
|
|
92
97
|
export async function createPgStoreConfig(connectionString: string) {
|
|
93
98
|
const pool = new Pool({ connectionString });
|
|
@@ -147,6 +152,9 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
147
152
|
mm.register(addWorkflowRunsTenantStatusUpdatedIndex); // v161
|
|
148
153
|
mm.register(addProjectKindColumn); // v165
|
|
149
154
|
mm.register(addA2AKeyAssistantIds); // v167
|
|
155
|
+
mm.register(addApiKeyGrants); // v178
|
|
156
|
+
mm.register(createOpenAuditTable); // v179
|
|
157
|
+
mm.register(addShareOpenGrants); // v180
|
|
150
158
|
mm.register(addTaskWorkItemEventKeyMigration); // v168
|
|
151
159
|
mm.register(createAgentWebAppsTable); // v169
|
|
152
160
|
mm.register(createCapabilityBundlesTable); // v170
|
|
@@ -155,6 +163,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
155
163
|
mm.register(addTrustedRunContextColumn); // v173
|
|
156
164
|
mm.register(addProjectLifecycleEventIndex); // v175
|
|
157
165
|
mm.register(addAgentWebAppsIdentity); // v176
|
|
166
|
+
mm.register(createProjectRoomReadStates); // v177
|
|
158
167
|
|
|
159
168
|
await mm.migrate();
|
|
160
169
|
|
|
@@ -187,6 +196,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
187
196
|
task: new PostgreSQLTaskStore(opts),
|
|
188
197
|
taskWorkItem: taskWorkItemStore,
|
|
189
198
|
a2aApiKey: new PostgreSQLA2AApiKeyStore(opts),
|
|
199
|
+
openAudit: new PostgreSQLOpenAuditStore(opts),
|
|
190
200
|
agentWebApp: new PostgreSQLAgentWebAppStore(opts),
|
|
191
201
|
capabilityBundle: new PostgreSQLCapabilityBundleStore(opts),
|
|
192
202
|
schedule: new PostgreSQLScheduleStorage(opts),
|
|
@@ -197,6 +207,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
197
207
|
projectMembership: new PostgreSQLProjectMembershipStore(opts),
|
|
198
208
|
projectBotMembership: new PostgreSQLProjectBotMembershipStore(opts),
|
|
199
209
|
projectRoomMessage: new PostgreSQLProjectRoomMessageStore(opts),
|
|
210
|
+
projectRoomReadState: new PostgreSQLProjectRoomReadStateStore(opts),
|
|
200
211
|
vectorStoreProvider: new PGVectorStoreProvider(pool, connectionString),
|
|
201
212
|
checkpoint,
|
|
202
213
|
};
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,7 @@ export * from "./stores/ThreadMessageQueueStore";
|
|
|
57
57
|
export * from "./stores/ChannelIdentityMappingStore";
|
|
58
58
|
export * from "./stores/PostgreSQLChannelInstallationStore";
|
|
59
59
|
export * from "./stores/PostgreSQLA2AApiKeyStore";
|
|
60
|
+
export * from "./stores/PostgreSQLOpenAuditStore";
|
|
60
61
|
export * from "./stores/PostgreSQLAgentWebAppStore";
|
|
61
62
|
export * from "./stores/PostgreSQLTaskWorkItemStore";
|
|
62
63
|
export * from "./stores/PostgreSQLCapabilityBundleStore";
|
|
@@ -75,6 +76,7 @@ export * from "./stores/PostgreSQLProjectRoomStore";
|
|
|
75
76
|
export * from "./stores/PostgreSQLProjectMembershipStore";
|
|
76
77
|
export * from "./stores/PostgreSQLProjectBotMembershipStore";
|
|
77
78
|
export * from "./stores/PostgreSQLProjectRoomMessageStore";
|
|
79
|
+
export * from "./stores/PostgreSQLProjectRoomReadStateStore";
|
|
78
80
|
export * from "./migrations/project_room_migration";
|
|
79
81
|
|
|
80
82
|
export * from "./PGVectorStoreProvider";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
import type { PoolClient } from "pg";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds the Open-door grants JSONB column to API keys.
|
|
6
|
+
*
|
|
7
|
+
* Absent/empty grants → legacy derivation via effectiveGrants()
|
|
8
|
+
* (assistantIds → agent-domain grant). The A2A door never reads this column.
|
|
9
|
+
*/
|
|
10
|
+
export const addApiKeyGrants: Migration = {
|
|
11
|
+
version: 178,
|
|
12
|
+
name: "add_api_key_grants",
|
|
13
|
+
up: async (client: PoolClient) => {
|
|
14
|
+
await client.query(`
|
|
15
|
+
ALTER TABLE lattice_a2a_api_keys
|
|
16
|
+
ADD COLUMN IF NOT EXISTS grants JSONB
|
|
17
|
+
`);
|
|
18
|
+
},
|
|
19
|
+
down: async (client: PoolClient) => {
|
|
20
|
+
await client.query(`
|
|
21
|
+
ALTER TABLE lattice_a2a_api_keys
|
|
22
|
+
DROP COLUMN IF EXISTS grants
|
|
23
|
+
`);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Migration } from "./migration";
|
|
2
|
+
import type { PoolClient } from "pg";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds Open-door grants to resource shares. Sandbox tokens minted for a share
|
|
6
|
+
* append these (sanitized against the V1 whitelist) to their self grant.
|
|
7
|
+
*/
|
|
8
|
+
export const addShareOpenGrants: Migration = {
|
|
9
|
+
version: 180,
|
|
10
|
+
name: "add_share_open_grants",
|
|
11
|
+
up: async (client: PoolClient) => {
|
|
12
|
+
await client.query(`
|
|
13
|
+
ALTER TABLE lattice_shared_resources
|
|
14
|
+
ADD COLUMN IF NOT EXISTS open_grants JSONB
|
|
15
|
+
`);
|
|
16
|
+
},
|
|
17
|
+
down: async (client: PoolClient) => {
|
|
18
|
+
await client.query(`
|
|
19
|
+
ALTER TABLE lattice_shared_resources
|
|
20
|
+
DROP COLUMN IF EXISTS open_grants
|
|
21
|
+
`);
|
|
22
|
+
},
|
|
23
|
+
};
|