@axiom-lattice/local-stores 3.1.5 → 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 +30 -0
- package/dist/index.d.mts +26 -2
- package/dist/index.d.ts +26 -2
- package/dist/index.js +126 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalMcpServerConfigStore.test.ts +77 -0
- package/src/__tests__/LocalProjectRoomReadStateStore.test.ts +101 -0
- package/src/__tests__/LocalProjectRoomStores.test.ts +39 -0
- package/src/createLocalStoreConfig.ts +2 -0
- package/src/index.ts +1 -0
- package/src/stores/LocalMcpServerConfigStore.ts +22 -0
- package/src/stores/LocalProjectRoomMessageStore.ts +14 -0
- package/src/stores/LocalProjectRoomReadStateStore.ts +50 -0
- package/src/stores/project-room-schema.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/local-stores",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.8",
|
|
4
4
|
"description": "Local SQLite-based stores for Axiom Lattice framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@types/sql.js": "^1.4.11",
|
|
26
26
|
"sql.js": "^1.14.1",
|
|
27
27
|
"uuid": "^14.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,77 @@
|
|
|
1
|
+
// The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
|
|
2
|
+
// transitive deps), so mock it with the real encryption module — node:crypto only.
|
|
3
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
4
|
+
...jest.requireActual("../../../core/src/util/encryption"),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
import { closeDatabase, initDatabase } from "../database";
|
|
12
|
+
import { LocalMcpServerConfigStore } from "../stores/LocalMcpServerConfigStore";
|
|
13
|
+
|
|
14
|
+
const nextDbPath = (): string => path.join(
|
|
15
|
+
tmpdir(), `lt-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const httpConfig = {
|
|
19
|
+
name: "remote",
|
|
20
|
+
version: "1.0.0",
|
|
21
|
+
transport: "streamable_http" as const,
|
|
22
|
+
url: "https://mcp.example.com/mcp",
|
|
23
|
+
headers: { Authorization: "Bearer sk-secret-token" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("LocalMcpServerConfigStore secret handling", () => {
|
|
27
|
+
let store: LocalMcpServerConfigStore;
|
|
28
|
+
let db: Awaited<ReturnType<typeof initDatabase>>;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
db = await initDatabase({ dbPath: nextDbPath() });
|
|
32
|
+
store = new LocalMcpServerConfigStore(db);
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => closeDatabase());
|
|
35
|
+
|
|
36
|
+
it("encrypts env and headers at rest and decrypts on read", async () => {
|
|
37
|
+
await store.createConfig("tenant-1", "cfg-1", {
|
|
38
|
+
key: "remote",
|
|
39
|
+
config: { ...httpConfig, env: { MCP_TOKEN: "env-secret" } },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const row = db.prepare(
|
|
43
|
+
"SELECT config, is_env_encrypted FROM lt_mcp_configs WHERE id = ?",
|
|
44
|
+
).get("cfg-1") as unknown as { config: string; is_env_encrypted: number };
|
|
45
|
+
expect(row.is_env_encrypted).toBe(1);
|
|
46
|
+
const atRest = JSON.parse(row.config);
|
|
47
|
+
expect(atRest.env.MCP_TOKEN).not.toBe("env-secret");
|
|
48
|
+
expect(atRest.headers.Authorization).not.toBe("Bearer sk-secret-token");
|
|
49
|
+
|
|
50
|
+
const read = await store.getConfigByKey("tenant-1", "remote");
|
|
51
|
+
expect(read?.config.env?.MCP_TOKEN).toBe("env-secret");
|
|
52
|
+
expect(read?.config.headers?.Authorization).toBe("Bearer sk-secret-token");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("round-trips configs without secrets and leaves no headers key", async () => {
|
|
56
|
+
await store.createConfig("tenant-1", "cfg-2", {
|
|
57
|
+
key: "plain",
|
|
58
|
+
config: {
|
|
59
|
+
name: "plain", version: "1.0.0", transport: "stdio",
|
|
60
|
+
command: "npx", args: ["-y", "@modelcontextprotocol/server-everything"],
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const read = await store.getConfigByKey("tenant-1", "plain");
|
|
64
|
+
expect(read?.config.headers).toBeUndefined();
|
|
65
|
+
expect(read?.config.command).toBe("npx");
|
|
66
|
+
expect(read?.isEnvEncrypted).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("re-encrypts rotated header secrets on update", async () => {
|
|
70
|
+
await store.createConfig("tenant-1", "cfg-3", { key: "remote", config: httpConfig });
|
|
71
|
+
await store.updateConfig("tenant-1", "cfg-3", {
|
|
72
|
+
config: { ...httpConfig, headers: { Authorization: "Bearer rotated-token" } },
|
|
73
|
+
});
|
|
74
|
+
const read = await store.getConfigByKey("tenant-1", "remote");
|
|
75
|
+
expect(read?.config.headers?.Authorization).toBe("Bearer rotated-token");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { existsSync, unlinkSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { closeDatabase, initDatabase } from "../database";
|
|
6
|
+
import { LocalProjectRoomReadStateStore } from "../stores/LocalProjectRoomReadStateStore";
|
|
7
|
+
|
|
8
|
+
const nextDbPath = (): string => path.join(tmpdir(), `lt-read-state-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
9
|
+
|
|
10
|
+
describe("LocalProjectRoomReadStateStore", () => {
|
|
11
|
+
let dbPath: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => { dbPath = nextDbPath(); });
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
closeDatabase();
|
|
16
|
+
if (existsSync(dbPath)) unlinkSync(dbPath);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("upserts monotonically and isolates scope", async () => {
|
|
20
|
+
const store = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
|
|
21
|
+
await expect(store.get("t1", "room-1", "u1")).resolves.toBeNull();
|
|
22
|
+
await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(1000) });
|
|
23
|
+
await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(2000) });
|
|
24
|
+
await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(500) });
|
|
25
|
+
expect((await store.get("t1", "room-1", "u1"))?.lastReadAt.getTime()).toBe(2000);
|
|
26
|
+
await expect(store.get("t1", "room-2", "u1")).resolves.toBeNull();
|
|
27
|
+
await expect(store.get("t2", "room-1", "u1")).resolves.toBeNull();
|
|
28
|
+
await expect(store.get("t1", "room-1", "u2")).resolves.toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects an invalid lastReadAt without writing", async () => {
|
|
32
|
+
const store = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
|
|
33
|
+
await expect(store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(Number.NaN) })).rejects.toBeInstanceOf(RangeError);
|
|
34
|
+
await expect(store.get("t1", "room-1", "u1")).resolves.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("persists markers across reopen with hydrated dates", async () => {
|
|
38
|
+
const initial = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
|
|
39
|
+
const saved = await initial.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(1500) });
|
|
40
|
+
expect(saved.lastReadAt.getTime()).toBe(1500);
|
|
41
|
+
expect(saved.updatedAt).toBeInstanceOf(Date);
|
|
42
|
+
closeDatabase();
|
|
43
|
+
|
|
44
|
+
const reopened = new LocalProjectRoomReadStateStore(await initDatabase({ dbPath }));
|
|
45
|
+
const persisted = await reopened.get("t1", "room-1", "u1");
|
|
46
|
+
expect(persisted).toEqual(saved);
|
|
47
|
+
expect(persisted?.lastReadAt).toBeInstanceOf(Date);
|
|
48
|
+
expect(persisted?.updatedAt).toBeInstanceOf(Date);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("upgrades a version-1 project room database by adding the read-state table", async () => {
|
|
52
|
+
const db = await initDatabase({ dbPath });
|
|
53
|
+
db.exec(`CREATE TABLE lt_project_rooms (
|
|
54
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL,
|
|
55
|
+
type TEXT NOT NULL CHECK (type = 'main'), name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
56
|
+
PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, type)
|
|
57
|
+
);
|
|
58
|
+
CREATE TABLE lt_project_memberships (
|
|
59
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, project_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
|
60
|
+
role TEXT NOT NULL CHECK (role IN ('owner','admin','member','viewer')),
|
|
61
|
+
status TEXT NOT NULL CHECK (status IN ('active','removed')), joined_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
62
|
+
PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, project_id, user_id)
|
|
63
|
+
);
|
|
64
|
+
CREATE INDEX idx_lt_project_memberships_project ON lt_project_memberships (tenant_id, project_id);
|
|
65
|
+
CREATE TABLE lt_project_bot_memberships (
|
|
66
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL,
|
|
67
|
+
room_id TEXT NOT NULL, assistant_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('coordinator','specialist')),
|
|
68
|
+
title TEXT NOT NULL, responsibility TEXT, mention_name TEXT NOT NULL,
|
|
69
|
+
status TEXT NOT NULL CHECK (status IN ('active','paused','removed')), room_thread_id TEXT NOT NULL,
|
|
70
|
+
joined_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (tenant_id, id),
|
|
71
|
+
UNIQUE (tenant_id, project_id, assistant_id)
|
|
72
|
+
);
|
|
73
|
+
CREATE UNIQUE INDEX uq_lt_project_bot_memberships_coordinator
|
|
74
|
+
ON lt_project_bot_memberships (tenant_id, project_id) WHERE role = 'coordinator' AND status IN ('active','paused');
|
|
75
|
+
CREATE UNIQUE INDEX uq_lt_project_bot_memberships_mention
|
|
76
|
+
ON lt_project_bot_memberships (tenant_id, room_id, mention_name) WHERE status IN ('active','paused');
|
|
77
|
+
CREATE INDEX idx_lt_project_bot_memberships_project ON lt_project_bot_memberships (tenant_id, project_id);
|
|
78
|
+
CREATE INDEX idx_lt_project_bot_memberships_room ON lt_project_bot_memberships (tenant_id, room_id);
|
|
79
|
+
CREATE TABLE lt_project_room_messages (
|
|
80
|
+
id TEXT NOT NULL, tenant_id TEXT NOT NULL, workspace_id TEXT NOT NULL, project_id TEXT NOT NULL, room_id TEXT NOT NULL,
|
|
81
|
+
author TEXT NOT NULL, content TEXT NOT NULL, mentions TEXT NOT NULL DEFAULT '[]', reply_to_message_id TEXT,
|
|
82
|
+
source TEXT NOT NULL CHECK (source IN ('user','agent','task','routine','system')), source_id TEXT,
|
|
83
|
+
idempotency_key TEXT, created_at TEXT NOT NULL, PRIMARY KEY (tenant_id, id)
|
|
84
|
+
);
|
|
85
|
+
CREATE UNIQUE INDEX uq_lt_project_room_messages_idempotency
|
|
86
|
+
ON lt_project_room_messages (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
87
|
+
CREATE INDEX idx_lt_project_room_messages_room_created
|
|
88
|
+
ON lt_project_room_messages (tenant_id, room_id, created_at DESC, id DESC);
|
|
89
|
+
CREATE TABLE lattice_schema_migrations (
|
|
90
|
+
store_name TEXT NOT NULL, name TEXT NOT NULL, version INTEGER NOT NULL, applied_at TEXT NOT NULL,
|
|
91
|
+
PRIMARY KEY (store_name, name)
|
|
92
|
+
);`);
|
|
93
|
+
db.getRawDb().run("INSERT INTO lattice_schema_migrations (store_name, name, version, applied_at) VALUES (?, ?, ?, ?)", ["project_room", "create_project_room_tables", 1, new Date().toISOString()]);
|
|
94
|
+
|
|
95
|
+
const store = new LocalProjectRoomReadStateStore(db);
|
|
96
|
+
await expect(store.get("t1", "room-1", "u1")).resolves.toBeNull();
|
|
97
|
+
await store.markRead({ tenantId: "t1", roomId: "room-1", userId: "u1", lastReadAt: new Date(1000) });
|
|
98
|
+
expect((await store.get("t1", "room-1", "u1"))?.lastReadAt.getTime()).toBe(1000);
|
|
99
|
+
expect(db.prepare("SELECT name, version FROM lattice_schema_migrations WHERE store_name = ? AND name = ?").get("project_room", "create_project_room_read_states")).toMatchObject({ name: "create_project_room_read_states", version: 2 });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -337,6 +337,45 @@ describe("local project room stores", () => {
|
|
|
337
337
|
await expect(store.list({ tenantId: "t1", roomId: "r1", before: { createdAt: new Date("invalid"), id: "x" }, limit: 1 })).rejects.toBeInstanceOf(RangeError);
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
it("counts messages strictly after the horizon with optional human-author exclusion", async () => {
|
|
341
|
+
const db = await initDatabase({ dbPath });
|
|
342
|
+
const store = new LocalProjectRoomMessageStore(db);
|
|
343
|
+
const insert = (id: string, author: string, createdAt: string) => transaction(db, () => {
|
|
344
|
+
write(db, "INSERT INTO lt_project_room_messages (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
345
|
+
[id, "t1", "w1", "p1", "r1", author, '{"type":"text","text":"text"}', "[]", null, "user", null, null, createdAt]);
|
|
346
|
+
});
|
|
347
|
+
insert("m1", '{"type":"human","userId":"u1"}', "2026-01-01T00:00:01.000Z");
|
|
348
|
+
insert("m2", '{"type":"human","userId":"u1"}', "2026-01-01T00:00:02.000Z");
|
|
349
|
+
insert("m3", '{"type":"human","userId":"u2"}', "2026-01-01T00:00:03.000Z");
|
|
350
|
+
insert("m4", '{"type":"bot","membershipId":"b1","assistantId":"a1"}', "2026-01-01T00:00:04.000Z");
|
|
351
|
+
const horizon = new Date("2026-01-01T00:00:01.000Z");
|
|
352
|
+
|
|
353
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: horizon })).toBe(3);
|
|
354
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date("2026-01-01T00:00:04.000Z") })).toBe(0);
|
|
355
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date(0) })).toBe(4);
|
|
356
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: horizon, excludeAuthorUserId: "u1" })).toBe(2);
|
|
357
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: horizon, excludeAuthorUserId: "u2" })).toBe(2);
|
|
358
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date("2026-01-01T00:00:03.000Z"), excludeAuthorUserId: "u2" })).toBe(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("scopes countAfter by tenant and room and rejects invalid horizons", async () => {
|
|
362
|
+
const db = await initDatabase({ dbPath });
|
|
363
|
+
const store = new LocalProjectRoomMessageStore(db);
|
|
364
|
+
const insert = (id: string, tenantId: string, roomId: string, createdAt: string) => transaction(db, () => {
|
|
365
|
+
write(db, "INSERT INTO lt_project_room_messages (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
366
|
+
[id, tenantId, "w1", "p1", roomId, '{"type":"system"}', '{"type":"text","text":"text"}', "[]", null, "system", null, null, createdAt]);
|
|
367
|
+
});
|
|
368
|
+
insert("m1", "t1", "r1", "2026-01-01T00:00:01.000Z");
|
|
369
|
+
insert("m2", "t1", "r2", "2026-01-01T00:00:02.000Z");
|
|
370
|
+
insert("m3", "t2", "r1", "2026-01-01T00:00:03.000Z");
|
|
371
|
+
|
|
372
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date(0) })).toBe(1);
|
|
373
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r2", after: new Date(0) })).toBe(1);
|
|
374
|
+
expect(await store.countAfter({ tenantId: "t2", roomId: "r1", after: new Date(0) })).toBe(1);
|
|
375
|
+
expect(await store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date("2026-01-01T00:00:01.000Z") })).toBe(0);
|
|
376
|
+
await expect(store.countAfter({ tenantId: "t1", roomId: "r1", after: new Date(Number.NaN) })).rejects.toBeInstanceOf(RangeError);
|
|
377
|
+
});
|
|
378
|
+
|
|
340
379
|
it("classifies a room message ID collision that occurs after preflight", async () => {
|
|
341
380
|
const db = await initDatabase({ dbPath });
|
|
342
381
|
const store = new LocalProjectRoomMessageStore(db);
|
|
@@ -24,6 +24,7 @@ import { LocalProjectRoomStore } from "./stores/LocalProjectRoomStore";
|
|
|
24
24
|
import { LocalProjectMembershipStore } from "./stores/LocalProjectMembershipStore";
|
|
25
25
|
import { LocalProjectBotMembershipStore } from "./stores/LocalProjectBotMembershipStore";
|
|
26
26
|
import { LocalProjectRoomMessageStore } from "./stores/LocalProjectRoomMessageStore";
|
|
27
|
+
import { LocalProjectRoomReadStateStore } from "./stores/LocalProjectRoomReadStateStore";
|
|
27
28
|
import { LocalUserStore } from "./stores/LocalUserStore";
|
|
28
29
|
import { LocalTenantStore } from "./stores/LocalTenantStore";
|
|
29
30
|
import { LocalUserTenantLinkStore } from "./stores/LocalUserTenantLinkStore";
|
|
@@ -74,6 +75,7 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
|
|
|
74
75
|
projectMembership: new LocalProjectMembershipStore(db),
|
|
75
76
|
projectBotMembership: new LocalProjectBotMembershipStore(db),
|
|
76
77
|
projectRoomMessage: new LocalProjectRoomMessageStore(db),
|
|
78
|
+
projectRoomReadState: new LocalProjectRoomReadStateStore(db),
|
|
77
79
|
user: new LocalUserStore(db),
|
|
78
80
|
tenant: new LocalTenantStore(db),
|
|
79
81
|
userTenantLink: new LocalUserTenantLinkStore(db),
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export { LocalProjectRoomStore } from "./stores/LocalProjectRoomStore";
|
|
|
35
35
|
export { LocalProjectMembershipStore, DuplicateProjectMembershipError, ProjectMembershipIdConflictError } from "./stores/LocalProjectMembershipStore";
|
|
36
36
|
export { LocalProjectBotMembershipStore, ProjectBotMembershipIdConflictError } from "./stores/LocalProjectBotMembershipStore";
|
|
37
37
|
export { LocalProjectRoomMessageStore, ProjectRoomMessageIdConflictError, DuplicateProjectRoomMessageIdempotencyKeyError } from "./stores/LocalProjectRoomMessageStore";
|
|
38
|
+
export { LocalProjectRoomReadStateStore } from "./stores/LocalProjectRoomReadStateStore";
|
|
38
39
|
export { LocalUserStore } from "./stores/LocalUserStore";
|
|
39
40
|
export { LocalTenantStore } from "./stores/LocalTenantStore";
|
|
40
41
|
export { LocalUserTenantLinkStore } from "./stores/LocalUserTenantLinkStore";
|
|
@@ -191,6 +191,19 @@ function mapRowToEntry(row: ConfigRow): McpServerConfigEntry {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
if (config.headers && row.is_env_encrypted) {
|
|
195
|
+
try {
|
|
196
|
+
const decryptedHeaders: Record<string, string> = {};
|
|
197
|
+
for (const [key, value] of Object.entries(config.headers)) {
|
|
198
|
+
decryptedHeaders[key] = decrypt(value);
|
|
199
|
+
}
|
|
200
|
+
config.headers = decryptedHeaders;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error("Failed to decrypt MCP server headers:", error);
|
|
203
|
+
throw new Error("Failed to decrypt MCP server configuration");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
194
207
|
return {
|
|
195
208
|
id: row.id,
|
|
196
209
|
tenantId: row.tenant_id,
|
|
@@ -219,5 +232,14 @@ function encryptEnvInConfig(config: McpServerConfig): { config: McpServerConfig;
|
|
|
219
232
|
isEnvEncrypted = true;
|
|
220
233
|
}
|
|
221
234
|
|
|
235
|
+
if (configCopy.headers && Object.keys(configCopy.headers).length > 0) {
|
|
236
|
+
const encryptedHeaders: Record<string, string> = {};
|
|
237
|
+
for (const [key, value] of Object.entries(configCopy.headers)) {
|
|
238
|
+
encryptedHeaders[key] = encrypt(value);
|
|
239
|
+
}
|
|
240
|
+
configCopy.headers = encryptedHeaders;
|
|
241
|
+
isEnvEncrypted = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
222
244
|
return { config: configCopy, isEnvEncrypted };
|
|
223
245
|
}
|
|
@@ -40,6 +40,20 @@ export class LocalProjectRoomMessageStore implements ProjectRoomMessageStore {
|
|
|
40
40
|
return (rows as Row[]).map(mapRow);
|
|
41
41
|
}
|
|
42
42
|
async findById(tenantId: string, id: string): Promise<ProjectRoomMessage | null> { const row = this.byId(tenantId, id); return row ? mapRow(row) : null; }
|
|
43
|
+
async countAfter(input: {
|
|
44
|
+
tenantId: string; roomId: string; after: Date; excludeAuthorUserId?: string;
|
|
45
|
+
}): Promise<number> {
|
|
46
|
+
if (!validDate(input.after)) throw new RangeError("Project room message count horizon date is invalid");
|
|
47
|
+
const rows = this.db.prepare(
|
|
48
|
+
`SELECT author FROM lt_project_room_messages
|
|
49
|
+
WHERE tenant_id = ? AND room_id = ? AND created_at > ?`,
|
|
50
|
+
).all(input.tenantId, input.roomId, input.after.toISOString()) as Array<{ author: string }>;
|
|
51
|
+
return rows.filter((row) => {
|
|
52
|
+
if (input.excludeAuthorUserId === undefined) return true;
|
|
53
|
+
const parsed = parseJson(row.author);
|
|
54
|
+
return !(record(parsed) && parsed.type === "human" && parsed.userId === input.excludeAuthorUserId);
|
|
55
|
+
}).length;
|
|
56
|
+
}
|
|
43
57
|
private insert(input: Omit<ProjectRoomMessage, "createdAt">, idempotent: boolean): ProjectRoomMessage {
|
|
44
58
|
return transaction(this.db, () => {
|
|
45
59
|
if (idempotent && input.idempotencyKey) { const canonical = this.byKey(input.tenantId, input.roomId, input.idempotencyKey); if (canonical) return mapRow(canonical); }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ProjectRoomReadState, ProjectRoomReadStateStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import type { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type ReadStateRow = { tenant_id: string; room_id: string; user_id: string; last_read_at: string; updated_at: string };
|
|
6
|
+
const columns = "tenant_id, room_id, user_id, last_read_at, updated_at";
|
|
7
|
+
|
|
8
|
+
function mapRow(row: ReadStateRow): ProjectRoomReadState {
|
|
9
|
+
const lastReadAt = new Date(row.last_read_at);
|
|
10
|
+
const updatedAt = new Date(row.updated_at);
|
|
11
|
+
if (Number.isNaN(lastReadAt.getTime()) || Number.isNaN(updatedAt.getTime())) throw new Error("Invalid project room read state row");
|
|
12
|
+
return { tenantId: row.tenant_id, roomId: row.room_id, userId: row.user_id, lastReadAt, updatedAt };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Persists per-user project room read markers in the local SQLite store. */
|
|
16
|
+
export class LocalProjectRoomReadStateStore implements ProjectRoomReadStateStore {
|
|
17
|
+
/** Creates a store over a managed local database wrapper. */
|
|
18
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
19
|
+
|
|
20
|
+
/** Returns the user's read marker for a room, or null when never reported. */
|
|
21
|
+
async get(tenantId: string, roomId: string, userId: string): Promise<ProjectRoomReadState | null> {
|
|
22
|
+
const row = this.byKey(tenantId, roomId, userId);
|
|
23
|
+
return row ? mapRow(row) : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Upserts the marker monotonically (an older report never regresses the horizon). */
|
|
27
|
+
async markRead(input: { tenantId: string; roomId: string; userId: string; lastReadAt: Date }): Promise<ProjectRoomReadState> {
|
|
28
|
+
if (!validDate(input.lastReadAt)) throw new RangeError("Project room read state lastReadAt is invalid");
|
|
29
|
+
return transaction(this.db, () => {
|
|
30
|
+
const existing = this.byKey(input.tenantId, input.roomId, input.userId);
|
|
31
|
+
const lastReadAt = existing !== undefined && new Date(existing.last_read_at).getTime() > input.lastReadAt.getTime()
|
|
32
|
+
? new Date(existing.last_read_at)
|
|
33
|
+
: input.lastReadAt;
|
|
34
|
+
write(this.db,
|
|
35
|
+
`INSERT INTO lt_project_room_read_states (${columns})
|
|
36
|
+
VALUES (?, ?, ?, ?, ?)
|
|
37
|
+
ON CONFLICT (tenant_id, room_id, user_id) DO UPDATE
|
|
38
|
+
SET last_read_at = excluded.last_read_at, updated_at = excluded.updated_at`,
|
|
39
|
+
[input.tenantId, input.roomId, input.userId, lastReadAt.toISOString(), timestamp()]);
|
|
40
|
+
const row = this.byKey(input.tenantId, input.roomId, input.userId) as ReadStateRow;
|
|
41
|
+
return mapRow(row);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private byKey(tenantId: string, roomId: string, userId: string): ReadStateRow | undefined {
|
|
46
|
+
return this.db.prepare(
|
|
47
|
+
`SELECT ${columns} FROM lt_project_room_read_states WHERE tenant_id = ? AND room_id = ? AND user_id = ?`,
|
|
48
|
+
).get(tenantId, roomId, userId) as ReadStateRow | undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -49,6 +49,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_lt_project_room_messages_idempotency
|
|
|
49
49
|
ON lt_project_room_messages (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
50
50
|
CREATE INDEX IF NOT EXISTS idx_lt_project_room_messages_room_created
|
|
51
51
|
ON lt_project_room_messages (tenant_id, room_id, created_at DESC, id DESC);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS lt_project_room_read_states (
|
|
53
|
+
tenant_id TEXT NOT NULL, room_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
|
54
|
+
last_read_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
55
|
+
PRIMARY KEY (tenant_id, room_id, user_id)
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_lt_project_room_read_states_user
|
|
58
|
+
ON lt_project_room_read_states (tenant_id, user_id);
|
|
52
59
|
`;
|
|
53
60
|
|
|
54
61
|
const tableColumns: Record<string, string[]> = {
|
|
@@ -56,6 +63,7 @@ const tableColumns: Record<string, string[]> = {
|
|
|
56
63
|
lt_project_memberships: ["id", "tenant_id", "project_id", "user_id", "role", "status", "joined_at", "updated_at"],
|
|
57
64
|
lt_project_bot_memberships: ["id", "tenant_id", "workspace_id", "project_id", "room_id", "assistant_id", "role", "title", "responsibility", "mention_name", "status", "room_thread_id", "joined_at", "updated_at"],
|
|
58
65
|
lt_project_room_messages: ["id", "tenant_id", "workspace_id", "project_id", "room_id", "author", "content", "mentions", "reply_to_message_id", "source", "source_id", "idempotency_key", "created_at"],
|
|
66
|
+
lt_project_room_read_states: ["tenant_id", "room_id", "user_id", "last_read_at", "updated_at"],
|
|
59
67
|
};
|
|
60
68
|
const indexes: Record<string, { table: string; columns: string[]; unique: boolean; partial: boolean; sql?: string[] }> = {
|
|
61
69
|
idx_lt_project_memberships_project: { table: "lt_project_memberships", columns: ["tenant_id", "project_id"], unique: false, partial: false },
|
|
@@ -65,12 +73,14 @@ const indexes: Record<string, { table: string; columns: string[]; unique: boolea
|
|
|
65
73
|
idx_lt_project_bot_memberships_room: { table: "lt_project_bot_memberships", columns: ["tenant_id", "room_id"], unique: false, partial: false },
|
|
66
74
|
uq_lt_project_room_messages_idempotency: { table: "lt_project_room_messages", columns: ["tenant_id", "room_id", "idempotency_key"], unique: true, partial: true, sql: ["idempotency_key is not null"] },
|
|
67
75
|
idx_lt_project_room_messages_room_created: { table: "lt_project_room_messages", columns: ["tenant_id", "room_id", "created_at", "id"], unique: false, partial: false, sql: ["created_at desc", "id desc"] },
|
|
76
|
+
idx_lt_project_room_read_states_user: { table: "lt_project_room_read_states", columns: ["tenant_id", "user_id"], unique: false, partial: false },
|
|
68
77
|
};
|
|
69
78
|
const uniqueConstraints: Record<string, string[][]> = {
|
|
70
79
|
lt_project_rooms: [["tenant_id", "id"], ["tenant_id", "project_id", "type"]],
|
|
71
80
|
lt_project_memberships: [["tenant_id", "id"], ["tenant_id", "project_id", "user_id"]],
|
|
72
81
|
lt_project_bot_memberships: [["tenant_id", "id"], ["tenant_id", "project_id", "assistant_id"]],
|
|
73
82
|
lt_project_room_messages: [["tenant_id", "id"]],
|
|
83
|
+
lt_project_room_read_states: [["tenant_id", "room_id", "user_id"]],
|
|
74
84
|
};
|
|
75
85
|
const checkConstraints: Record<string, Array<{ name: string; fragment: string }>> = {
|
|
76
86
|
lt_project_rooms: [{ name: "type", fragment: "type='main'" }],
|
|
@@ -83,6 +93,7 @@ const checkConstraints: Record<string, Array<{ name: string; fragment: string }>
|
|
|
83
93
|
{ name: "status", fragment: "status in ('active','paused','removed')" },
|
|
84
94
|
],
|
|
85
95
|
lt_project_room_messages: [{ name: "source", fragment: "source in ('user','agent','task','routine','system')" }],
|
|
96
|
+
lt_project_room_read_states: [],
|
|
86
97
|
};
|
|
87
98
|
|
|
88
99
|
function rows(db: DatabaseWrapper, sql: string): Record<string, unknown>[] {
|
|
@@ -179,6 +190,25 @@ export function ensureProjectRoomSchema(db: DatabaseWrapper): void {
|
|
|
179
190
|
const applied = hasMigrationTable ? rows(db, `SELECT name, version FROM lattice_schema_migrations WHERE store_name = 'project_room' AND name = '${migrationName}'`)[0] : undefined;
|
|
180
191
|
if (applied) {
|
|
181
192
|
if (applied.version !== 1) throw new Error(`Invalid project room schema migration version: ${String(applied.version)}`);
|
|
193
|
+
const hasReadStates = rows(db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'lt_project_room_read_states'").length > 0;
|
|
194
|
+
if (!hasReadStates) {
|
|
195
|
+
raw.exec("BEGIN IMMEDIATE");
|
|
196
|
+
try {
|
|
197
|
+
raw.exec(`CREATE TABLE IF NOT EXISTS lt_project_room_read_states (
|
|
198
|
+
tenant_id TEXT NOT NULL, room_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
|
199
|
+
last_read_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
200
|
+
PRIMARY KEY (tenant_id, room_id, user_id)
|
|
201
|
+
); CREATE INDEX IF NOT EXISTS idx_lt_project_room_read_states_user
|
|
202
|
+
ON lt_project_room_read_states (tenant_id, user_id);`);
|
|
203
|
+
raw.run("INSERT INTO lattice_schema_migrations (store_name, name, version, applied_at) VALUES (?, ?, ?, ?)",
|
|
204
|
+
["project_room", "create_project_room_read_states", 2, new Date().toISOString()]);
|
|
205
|
+
raw.exec("COMMIT");
|
|
206
|
+
db.save();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
try { raw.exec("ROLLBACK"); } catch { /* preserve the schema failure */ }
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
182
212
|
validateProjectRoomSchema(db);
|
|
183
213
|
marked[schemaMarker] = true;
|
|
184
214
|
return;
|