@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/local-stores",
3
- "version": "3.1.5",
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.5",
29
- "@axiom-lattice/protocols": "4.1.3"
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;