@axiom-lattice/pg-stores 3.1.6 → 3.1.8
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 +20 -0
- package/dist/index.d.mts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +100 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLMcpServerConfigStore.test.ts +79 -0
- package/src/__tests__/PostgreSQLProjectRoomReadStateStore.test.ts +136 -0
- package/src/createPgStoreConfig.ts +4 -1
- package/src/index.ts +1 -0
- package/src/migrations/project_room_migration.ts +26 -0
- package/src/stores/PostgreSQLMcpServerConfigStore.ts +23 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +16 -0
- package/src/stores/PostgreSQLProjectRoomReadStateStore.ts +47 -0
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.8",
|
|
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.
|
|
28
|
+
"@axiom-lattice/core": "4.2.8",
|
|
29
|
+
"@axiom-lattice/protocols": "4.2.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -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,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
|
+
});
|
|
@@ -82,12 +82,13 @@ import {
|
|
|
82
82
|
import { createAgentWebAppsTable } from "./migrations/agent_web_apps_migration";
|
|
83
83
|
import { addAgentWebAppsIdentity } from "./migrations/add_agent_web_apps_identity_migration";
|
|
84
84
|
import { createCapabilityBundlesTable } from "./migrations/capability_bundle_migration";
|
|
85
|
-
import { createProjectRoomTables } from "./migrations/project_room_migration";
|
|
85
|
+
import { createProjectRoomReadStates, createProjectRoomTables } from "./migrations/project_room_migration";
|
|
86
86
|
import { PostgreSQLCapabilityBundleStore } from "./stores/PostgreSQLCapabilityBundleStore";
|
|
87
87
|
import { PostgreSQLProjectRoomStore } from "./stores/PostgreSQLProjectRoomStore";
|
|
88
88
|
import { PostgreSQLProjectMembershipStore } from "./stores/PostgreSQLProjectMembershipStore";
|
|
89
89
|
import { PostgreSQLProjectBotMembershipStore } from "./stores/PostgreSQLProjectBotMembershipStore";
|
|
90
90
|
import { PostgreSQLProjectRoomMessageStore } from "./stores/PostgreSQLProjectRoomMessageStore";
|
|
91
|
+
import { PostgreSQLProjectRoomReadStateStore } from "./stores/PostgreSQLProjectRoomReadStateStore";
|
|
91
92
|
|
|
92
93
|
export async function createPgStoreConfig(connectionString: string) {
|
|
93
94
|
const pool = new Pool({ connectionString });
|
|
@@ -155,6 +156,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
155
156
|
mm.register(addTrustedRunContextColumn); // v173
|
|
156
157
|
mm.register(addProjectLifecycleEventIndex); // v175
|
|
157
158
|
mm.register(addAgentWebAppsIdentity); // v176
|
|
159
|
+
mm.register(createProjectRoomReadStates); // v177
|
|
158
160
|
|
|
159
161
|
await mm.migrate();
|
|
160
162
|
|
|
@@ -197,6 +199,7 @@ export async function createPgStoreConfig(connectionString: string) {
|
|
|
197
199
|
projectMembership: new PostgreSQLProjectMembershipStore(opts),
|
|
198
200
|
projectBotMembership: new PostgreSQLProjectBotMembershipStore(opts),
|
|
199
201
|
projectRoomMessage: new PostgreSQLProjectRoomMessageStore(opts),
|
|
202
|
+
projectRoomReadState: new PostgreSQLProjectRoomReadStateStore(opts),
|
|
200
203
|
vectorStoreProvider: new PGVectorStoreProvider(pool, connectionString),
|
|
201
204
|
checkpoint,
|
|
202
205
|
};
|
package/src/index.ts
CHANGED
|
@@ -75,6 +75,7 @@ export * from "./stores/PostgreSQLProjectRoomStore";
|
|
|
75
75
|
export * from "./stores/PostgreSQLProjectMembershipStore";
|
|
76
76
|
export * from "./stores/PostgreSQLProjectBotMembershipStore";
|
|
77
77
|
export * from "./stores/PostgreSQLProjectRoomMessageStore";
|
|
78
|
+
export * from "./stores/PostgreSQLProjectRoomReadStateStore";
|
|
78
79
|
export * from "./migrations/project_room_migration";
|
|
79
80
|
|
|
80
81
|
export * from "./PGVectorStoreProvider";
|
|
@@ -126,3 +126,29 @@ export const createProjectRoomTables: Migration = {
|
|
|
126
126
|
await client.query("DROP TABLE IF EXISTS lattice_project_rooms");
|
|
127
127
|
},
|
|
128
128
|
};
|
|
129
|
+
|
|
130
|
+
/** Adds per-user, per-room read markers for unread counts. */
|
|
131
|
+
export const createProjectRoomReadStates: Migration = {
|
|
132
|
+
version: 177,
|
|
133
|
+
name: "create_project_room_read_states",
|
|
134
|
+
up: async (client) => {
|
|
135
|
+
await client.query(`
|
|
136
|
+
CREATE TABLE IF NOT EXISTS lattice_project_room_read_states (
|
|
137
|
+
tenant_id VARCHAR(255) NOT NULL,
|
|
138
|
+
room_id VARCHAR(255) NOT NULL,
|
|
139
|
+
user_id VARCHAR(255) NOT NULL,
|
|
140
|
+
last_read_at TIMESTAMPTZ NOT NULL,
|
|
141
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
142
|
+
PRIMARY KEY (tenant_id, room_id, user_id)
|
|
143
|
+
)
|
|
144
|
+
`);
|
|
145
|
+
await client.query(`
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_lattice_project_room_read_states_user
|
|
147
|
+
ON lattice_project_room_read_states (tenant_id, user_id)
|
|
148
|
+
`);
|
|
149
|
+
},
|
|
150
|
+
down: async (client) => {
|
|
151
|
+
await client.query("DROP INDEX IF EXISTS idx_lattice_project_room_read_states_user");
|
|
152
|
+
await client.query("DROP TABLE IF EXISTS lattice_project_room_read_states");
|
|
153
|
+
},
|
|
154
|
+
};
|
|
@@ -464,6 +464,20 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
|
|
|
464
464
|
}
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
// Decrypt header values if present and encrypted
|
|
468
|
+
if (config.headers && row.is_env_encrypted) {
|
|
469
|
+
try {
|
|
470
|
+
const decryptedHeaders: Record<string, string> = {};
|
|
471
|
+
for (const [key, value] of Object.entries(config.headers)) {
|
|
472
|
+
decryptedHeaders[key] = decrypt(value);
|
|
473
|
+
}
|
|
474
|
+
config.headers = decryptedHeaders;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
console.error('Failed to decrypt MCP server headers:', error);
|
|
477
|
+
throw new Error('Failed to decrypt MCP server configuration');
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
467
481
|
return {
|
|
468
482
|
id: row.id,
|
|
469
483
|
tenantId: row.tenant_id,
|
|
@@ -495,6 +509,15 @@ export class PostgreSQLMcpServerConfigStore implements McpServerConfigStore {
|
|
|
495
509
|
isEnvEncrypted = true;
|
|
496
510
|
}
|
|
497
511
|
|
|
512
|
+
if (configCopy.headers && Object.keys(configCopy.headers).length > 0) {
|
|
513
|
+
const encryptedHeaders: Record<string, string> = {};
|
|
514
|
+
for (const [key, value] of Object.entries(configCopy.headers)) {
|
|
515
|
+
encryptedHeaders[key] = encrypt(value);
|
|
516
|
+
}
|
|
517
|
+
configCopy.headers = encryptedHeaders;
|
|
518
|
+
isEnvEncrypted = true;
|
|
519
|
+
}
|
|
520
|
+
|
|
498
521
|
return { config: configCopy, isEnvEncrypted };
|
|
499
522
|
}
|
|
500
523
|
}
|
|
@@ -170,6 +170,22 @@ export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStor
|
|
|
170
170
|
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** Counts messages created strictly after a horizon, optionally excluding one human author. */
|
|
174
|
+
async countAfter(input: {
|
|
175
|
+
tenantId: string; roomId: string; after: Date; excludeAuthorUserId?: string;
|
|
176
|
+
}): Promise<number> {
|
|
177
|
+
if (!(input.after instanceof Date) || Number.isNaN(input.after.getTime())) {
|
|
178
|
+
throw new RangeError("Project room message count horizon date is invalid");
|
|
179
|
+
}
|
|
180
|
+
const result = await this.pool.query<{ count: number }>(
|
|
181
|
+
`SELECT COUNT(*)::int AS count FROM lattice_project_room_messages
|
|
182
|
+
WHERE tenant_id = $1 AND room_id = $2 AND created_at > $3
|
|
183
|
+
AND ($4::text IS NULL OR author->>'type' <> 'human' OR author->>'userId' <> $4)`,
|
|
184
|
+
[input.tenantId, input.roomId, input.after, input.excludeAuthorUserId ?? null],
|
|
185
|
+
);
|
|
186
|
+
return result.rows[0]?.count ?? 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
173
189
|
private parameters(input: Omit<ProjectRoomMessage, "createdAt">): unknown[] {
|
|
174
190
|
return [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content),
|
|
175
191
|
JSON.stringify(input.mentions), input.replyToMessageId ?? null, input.source, input.sourceId ?? null, input.idempotencyKey ?? null];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ProjectRoomReadState, ProjectRoomReadStateStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
type ReadStateRow = { tenant_id: unknown; room_id: unknown; user_id: unknown; last_read_at: unknown; updated_at: unknown };
|
|
5
|
+
|
|
6
|
+
function mapRow(row: ReadStateRow): ProjectRoomReadState {
|
|
7
|
+
if (typeof row.tenant_id !== "string" || typeof row.room_id !== "string" || typeof row.user_id !== "string"
|
|
8
|
+
|| !(row.last_read_at instanceof Date) || Number.isNaN(row.last_read_at.getTime())
|
|
9
|
+
|| !(row.updated_at instanceof Date) || Number.isNaN(row.updated_at.getTime())) {
|
|
10
|
+
throw new Error("Invalid project room read state row");
|
|
11
|
+
}
|
|
12
|
+
return { tenantId: row.tenant_id, roomId: row.room_id, userId: row.user_id, lastReadAt: row.last_read_at, updatedAt: row.updated_at };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Persists tenant-isolated per-user project room read markers in PostgreSQL. */
|
|
16
|
+
export class PostgreSQLProjectRoomReadStateStore implements ProjectRoomReadStateStore {
|
|
17
|
+
/** Creates a store using an externally managed shared pool. */
|
|
18
|
+
constructor(options: { pool: Pool }) { this.pool = options.pool; }
|
|
19
|
+
private readonly pool: Pool;
|
|
20
|
+
|
|
21
|
+
/** Returns the user's read marker for a room, or null when never reported. */
|
|
22
|
+
async get(tenantId: string, roomId: string, userId: string): Promise<ProjectRoomReadState | null> {
|
|
23
|
+
const result = await this.pool.query<ReadStateRow>(
|
|
24
|
+
`SELECT tenant_id, room_id, user_id, last_read_at, updated_at
|
|
25
|
+
FROM lattice_project_room_read_states WHERE tenant_id = $1 AND room_id = $2 AND user_id = $3`,
|
|
26
|
+
[tenantId, roomId, userId],
|
|
27
|
+
);
|
|
28
|
+
return result.rows[0] ? mapRow(result.rows[0]) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Upserts the marker monotonically using GREATEST so older reports never regress it. */
|
|
32
|
+
async markRead(input: { tenantId: string; roomId: string; userId: string; lastReadAt: Date }): Promise<ProjectRoomReadState> {
|
|
33
|
+
if (!(input.lastReadAt instanceof Date) || Number.isNaN(input.lastReadAt.getTime())) {
|
|
34
|
+
throw new RangeError("Project room read state lastReadAt is invalid");
|
|
35
|
+
}
|
|
36
|
+
const result = await this.pool.query<ReadStateRow>(
|
|
37
|
+
`INSERT INTO lattice_project_room_read_states (tenant_id, room_id, user_id, last_read_at, updated_at)
|
|
38
|
+
VALUES ($1, $2, $3, $4, NOW())
|
|
39
|
+
ON CONFLICT (tenant_id, room_id, user_id) DO UPDATE
|
|
40
|
+
SET last_read_at = GREATEST(lattice_project_room_read_states.last_read_at, EXCLUDED.last_read_at),
|
|
41
|
+
updated_at = NOW()
|
|
42
|
+
RETURNING tenant_id, room_id, user_id, last_read_at, updated_at`,
|
|
43
|
+
[input.tenantId, input.roomId, input.userId, input.lastReadAt],
|
|
44
|
+
);
|
|
45
|
+
return mapRow(result.rows[0]);
|
|
46
|
+
}
|
|
47
|
+
}
|