@axiom-lattice/local-stores 3.1.1 → 3.1.2

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.
@@ -153,6 +153,34 @@ describe("LocalTaskStore metadata filter", () => {
153
153
  expect(await store.getById("t1", "a2a-task-1")).not.toBeNull();
154
154
  });
155
155
 
156
+ it("lists guarded JSON dependents and nonproject rows in deterministic order", async () => {
157
+ const base = { tenantId: "t1", ownerType: "agent", ownerId: "a1", workspaceId: "workspace-1", projectId: "project-1" } as const;
158
+ for (const [id, status, dependencies] of [
159
+ ["dependent-a", "pending", ["dependency-1"]],
160
+ ["dependent-b", "interrupted", ["dependency-1", "dependency-1"]],
161
+ ["dependent-c", "pending", ["dependency-1"]],
162
+ ] as const) await store.create({ ...base, id, title: id, status, dependencies });
163
+ db.prepare("UPDATE lt_tasks SET created_at = ? WHERE tenant_id = ?").run("2026-01-01T00:00:00.000Z", "t1");
164
+ for (const [id, raw] of [["null-json", null], ["empty-json", ""], ["malformed-json", "{"], ["object-json", "{}"]] as const) {
165
+ await store.create({ ...base, id, title: id, status: "pending" });
166
+ db.prepare("UPDATE lt_tasks SET dependencies = ? WHERE tenant_id = ? AND id = ?").run(raw, "t1", id);
167
+ }
168
+ await store.create({ ...base, id: "wrong-project", title: "wrong", status: "pending", dependencies: ["dependency-1"], projectId: "project-2" });
169
+ const query = { tenantId: "t1", workspaceId: "workspace-1", projectId: "project-1", dependencyTaskId: "dependency-1", statuses: ["pending", "interrupted"], limit: 2, offset: 0 } as const;
170
+
171
+ expect((await store.listDependents(query)).map(({ id }) => id)).toEqual(["dependent-c", "dependent-b"]);
172
+ await expect(store.listDependents({ ...query, statuses: [] })).rejects.toThrow(RangeError);
173
+ await expect(store.listDependents({ ...query, statuses: ["unknown"] as never })).rejects.toThrow(RangeError);
174
+ await expect(store.listDependents({ ...query, limit: 101 })).rejects.toThrow(RangeError);
175
+
176
+ for (const [id, projectId] of [["nonproject-d", undefined], ["nonproject-c", ""], ["nonproject-b", "default"]] as const) {
177
+ await store.create({ id, tenantId: "t1", ownerType: "user", ownerId: "u1", title: id, projectId });
178
+ }
179
+ db.prepare("UPDATE lt_tasks SET created_at = ? WHERE id LIKE 'nonproject-%'").run("2026-01-02T00:00:00.000Z");
180
+ expect((await store.list({ tenantId: "t1", projectId: null, limit: 2, offset: 1 })).map(({ id }) => id))
181
+ .toEqual(["nonproject-c", "nonproject-b"]);
182
+ });
183
+
156
184
  it("conditionally updates through independent store instances", async () => {
157
185
  const secondStore = new LocalTaskStore(db);
158
186
  await store.create({
@@ -242,6 +270,36 @@ describe("LocalTaskStore metadata filter", () => {
242
270
  expect((await store.getById("t1", created.id))?.status).toBe("in_progress");
243
271
  });
244
272
 
273
+ it("conditionally updates and deletes only an exact owner and project snapshot", async () => {
274
+ const created = await store.create({
275
+ id: "task-exact-snapshot", tenantId: "t1", workspaceId: "workspace-1", projectId: "project-1",
276
+ ownerType: "agent", ownerId: "agent-1", title: "Working", status: "pending",
277
+ });
278
+ const snapshot = {
279
+ status: created.status, updatedAt: created.updatedAt, ownerType: created.ownerType,
280
+ ownerId: created.ownerId, workspaceId: "workspace-1", projectId: "project-1",
281
+ };
282
+
283
+ await expect(store.updateIfSnapshot(
284
+ "t1", created.id, { title: "Wrong" }, { ...snapshot, projectId: "project-2" },
285
+ )).resolves.toBeNull();
286
+ const updated = await store.updateIfSnapshot("t1", created.id, { title: "Updated" }, snapshot);
287
+ expect(updated?.title).toBe("Updated");
288
+ await expect(store.deleteIfSnapshot("t1", created.id, snapshot)).resolves.toBe(false);
289
+ await expect(store.deleteIfSnapshot("t1", created.id, {
290
+ ...snapshot, updatedAt: updated!.updatedAt,
291
+ })).resolves.toBe(true);
292
+ });
293
+
294
+ it("conditionally mutates an exact nonproject snapshot", async () => {
295
+ const created = await store.create({ id: "nonproject-cas", tenantId: "t1", ownerType: "user", ownerId: "user-1", title: "Personal" });
296
+ const snapshot = { status: created.status, updatedAt: created.updatedAt, ownerType: created.ownerType,
297
+ ownerId: created.ownerId, workspaceId: null, projectId: null };
298
+ const updated = await store.updateIfSnapshot("t1", created.id, { title: "Updated" }, snapshot);
299
+ expect(updated?.title).toBe("Updated");
300
+ await expect(store.deleteIfSnapshot("t1", created.id, { ...snapshot, updatedAt: updated!.updatedAt })).resolves.toBe(true);
301
+ });
302
+
245
303
  it("atomically matches both child and parent snapshots", async () => {
246
304
  const parent = await store.create({
247
305
  id: "parent-snapshot", tenantId: "t1", ownerType: "agent", ownerId: "a1", title: "Parent",
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import { unlinkSync } from "fs";
4
4
  import { closeDatabase, initDatabase, type DatabaseWrapper } from "../database";
5
5
  import { LocalTaskWorkItemStore } from "../stores/LocalTaskWorkItemStore";
6
+ import { LocalTaskStore } from "../stores/LocalTaskStore";
6
7
 
7
8
  jest.mock("uuid", () => {
8
9
  let id = 0;
@@ -42,6 +43,33 @@ async function openTaskWorkItemStore(dbPath: string): Promise<LocalTaskWorkItemS
42
43
  }
43
44
 
44
45
  describe("LocalTaskWorkItemStore", () => {
46
+ it("atomically inserts activity only while the task snapshot matches", async () => {
47
+ const dbPath = path.join(tmpdir(), `lt-task-activity-snapshot-${Date.now()}.db`);
48
+ try {
49
+ const db = await initDatabase({ dbPath });
50
+ const tasks = new LocalTaskStore(db);
51
+ const store = new LocalTaskWorkItemStore(db);
52
+ const task = await tasks.create({
53
+ id: "task-1", tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1",
54
+ ownerType: "agent", ownerId: "agent-1", title: "Task", status: "in_progress",
55
+ });
56
+ const snapshot = {
57
+ status: task.status, updatedAt: task.updatedAt, ownerType: task.ownerType, ownerId: task.ownerId,
58
+ workspaceId: "workspace-1", projectId: "project-1",
59
+ };
60
+ await expect(store.createIfTaskSnapshot({
61
+ tenantId: "tenant-1", taskId: "task-1", action: "activity", actor: "agent:agent-1",
62
+ workspaceId: "workspace-1", projectId: "project-1",
63
+ }, { ...snapshot, status: "pending" })).resolves.toBeNull();
64
+ await expect(store.createIfTaskSnapshot({
65
+ tenantId: "tenant-1", taskId: "task-1", action: "activity", actor: "agent:agent-1",
66
+ workspaceId: "workspace-1", projectId: "project-1",
67
+ }, snapshot)).resolves.toMatchObject({ taskId: "task-1" });
68
+ } finally {
69
+ closeDatabase();
70
+ unlinkSync(dbPath);
71
+ }
72
+ });
45
73
  it("persists Markdown activity across store and database instances", async () => {
46
74
  const dbPath = path.join(
47
75
  tmpdir(),
@@ -275,6 +303,45 @@ describe("LocalTaskWorkItemStore", () => {
275
303
  }
276
304
  });
277
305
 
306
+ it("pages project lifecycle events and rejects malformed outer rows", async () => {
307
+ const dbPath = path.join(tmpdir(), `lt-task-work-item-project-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
308
+ try {
309
+ const store = await openTaskWorkItemStore(dbPath);
310
+ const db = await initDatabase({ dbPath });
311
+ for (const [taskId, action, eventKey] of [["task-a", "in_progress", "event:a"], ["task-b", "completed", "event:b"], ["task-c", "in_progress", "event:c"]] as const) {
312
+ await store.createIfAbsentByEventKey({ tenantId: "tenant-1", taskId, action, eventKey, actor: "agent:a", workspaceId: "workspace-1", projectId: "project-1" });
313
+ }
314
+ db.prepare("UPDATE lt_task_work_items SET id = CASE task_id WHEN 'task-a' THEN 'event-a' WHEN 'task-b' THEN 'event-b' ELSE 'event-c' END, created_at = CASE task_id WHEN 'task-a' THEN '2026-01-01T00:00:00.000Z' ELSE '2026-01-02T00:00:00.000Z' END").run();
315
+ await store.create({ tenantId: "tenant-1", taskId: "missing-key", action: "completed", actor: "agent:a", workspaceId: "workspace-1", projectId: "project-1" });
316
+ await store.createIfAbsentByEventKey({ tenantId: "tenant-2", taskId: "wrong-tenant", action: "completed", eventKey: "event:tenant", actor: "agent:a", workspaceId: "workspace-1", projectId: "project-1" });
317
+ await store.createIfAbsentByEventKey({ tenantId: "tenant-1", taskId: "wrong-workspace", action: "completed", eventKey: "event:workspace", actor: "agent:a", workspaceId: "workspace-2", projectId: "project-1" });
318
+ await store.createIfAbsentByEventKey({ tenantId: "tenant-1", taskId: "wrong-project", action: "completed", eventKey: "event:project", actor: "agent:a", workspaceId: "workspace-1", projectId: "project-2" });
319
+ await store.createIfAbsentByEventKey({ tenantId: "tenant-1", taskId: "wrong-action", action: "activity", eventKey: "event:action", actor: "agent:a", workspaceId: "workspace-1", projectId: "project-1" });
320
+ const query = { tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1", actions: ["in_progress", "completed"], limit: 2 } as const;
321
+
322
+ const page1 = await store.listProjectLifecycleEvents(query);
323
+ expect(page1.map(({ id }) => id)).toEqual(["event-c", "event-b"]);
324
+ expect((await store.listProjectLifecycleEvents({ ...query, before: { createdAt: page1[1].createdAt, id: page1[1].id } })).map(({ id }) => id)).toEqual(["event-a"]);
325
+ await expect(store.listProjectLifecycleEvents({ ...query, actions: [] })).rejects.toThrow(RangeError);
326
+ await expect(store.listProjectLifecycleEvents({ ...query, actions: ["unknown"] as never })).rejects.toThrow(RangeError);
327
+ await expect(store.listProjectLifecycleEvents({ ...query, limit: 101 })).rejects.toThrow(RangeError);
328
+
329
+ db.prepare("UPDATE lt_task_work_items SET detail = '{' WHERE task_id = 'task-a'").run();
330
+ const legacy = await store.listProjectLifecycleEvents({ ...query, limit: 10 });
331
+ expect(legacy.find(({ id }) => id === "event-a")?.detail).toBeUndefined();
332
+ for (const detail of ["[]", "null", '"legacy"', "1"]) {
333
+ db.prepare("UPDATE lt_task_work_items SET detail = ? WHERE task_id = 'task-a'").run(detail);
334
+ expect((await store.listProjectLifecycleEvents({ ...query, limit: 10 }))
335
+ .find(({ id }) => id === "event-a")?.detail).toBeUndefined();
336
+ }
337
+
338
+ db.prepare("UPDATE lt_task_work_items SET id = '' WHERE task_id = 'task-a'").run();
339
+ await expect(store.listProjectLifecycleEvents({ ...query, limit: 10 })).rejects.toThrow("Invalid project lifecycle event row");
340
+ } finally {
341
+ closeDatabase();
342
+ unlinkSync(dbPath);
343
+ }
344
+ });
278
345
  it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1, 1_001])(
279
346
  "rejects invalid pending execution result limit %s",
280
347
  async (limit) => {
@@ -319,4 +386,29 @@ describe("LocalTaskWorkItemStore", () => {
319
386
  /json_extract\(detail, '\$\.executionResultId'\)[\s\S]+WHERE action = 'execution_reconciled'/,
320
387
  ));
321
388
  });
389
+
390
+ it("serves immediate pending evidence and Project lifecycle history from one database", async () => {
391
+ const dbPath = path.join(tmpdir(), `lt-task-work-item-combined-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
392
+ try {
393
+ const store = await openTaskWorkItemStore(dbPath);
394
+ await store.createIfAbsentByEventKey({
395
+ tenantId: "tenant-1", taskId: "task-1", eventKey: "execution-result:ordinary-thread",
396
+ action: "execution_result", actor: "agent:worker", workspaceId: "workspace-1", projectId: "project-1",
397
+ });
398
+ await store.createIfAbsentByEventKey({
399
+ tenantId: "tenant-1", taskId: "task-1", eventKey: "project-completed:task-1",
400
+ action: "completed", actor: "agent:project-worker", workspaceId: "workspace-1", projectId: "project-1",
401
+ });
402
+
403
+ await expect(store.listPendingExecutionResults({ tenantId: "tenant-1", taskId: "task-1", limit: 10 }))
404
+ .resolves.toEqual([expect.objectContaining({ eventKey: "execution-result:ordinary-thread" })]);
405
+ await expect(store.listProjectLifecycleEvents({
406
+ tenantId: "tenant-1", workspaceId: "workspace-1", projectId: "project-1",
407
+ actions: ["completed"], limit: 10,
408
+ })).resolves.toEqual([expect.objectContaining({ eventKey: "project-completed:task-1" })]);
409
+ } finally {
410
+ closeDatabase();
411
+ unlinkSync(dbPath);
412
+ }
413
+ });
322
414
  });
@@ -1,15 +1,30 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
2
2
  import path from "path";
3
3
  import { tmpdir } from "os";
4
- import { closeDatabase, initDatabase } from "../database";
4
+ import { closeDatabase, initDatabase, type DatabaseWrapper } from "../database";
5
5
  import { LocalThreadMessageQueueStore } from "../stores/LocalThreadMessageQueueStore";
6
+ import type { AddMessageParams } from "@axiom-lattice/core";
6
7
 
7
8
  describe("LocalThreadMessageQueueStore scope isolation", () => {
8
9
  let store: LocalThreadMessageQueueStore;
10
+ let db: DatabaseWrapper;
11
+ let dbPath: string;
12
+
13
+ function trustedContext(inputMessageId: string) {
14
+ return {
15
+ projectRoom: {
16
+ tenantId: "tenant", workspaceId: "workspace", projectId: "project",
17
+ roomId: "room", sourceRoomMessageId: "room-message", membershipId: "membership",
18
+ assistantId: "assistant", inputMessageId, role: "coordinator" as const,
19
+ title: "Coordinator",
20
+ },
21
+ };
22
+ }
9
23
 
10
24
  beforeEach(async () => {
11
- const dbPath = path.join(tmpdir(), `lt-queue-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
12
- store = new LocalThreadMessageQueueStore(await initDatabase({ dbPath }));
25
+ dbPath = path.join(tmpdir(), `lt-queue-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
26
+ db = await initDatabase({ dbPath });
27
+ store = new LocalThreadMessageQueueStore(db);
13
28
  });
14
29
 
15
30
  afterEach(() => closeDatabase());
@@ -75,4 +90,112 @@ describe("LocalThreadMessageQueueStore scope isolation", () => {
75
90
  projectId: null,
76
91
  })).toBe(2);
77
92
  });
93
+
94
+ it.each([
95
+ ["addMessage", (params: Parameters<LocalThreadMessageQueueStore["addMessage"]>[0]) => store.addMessage(params)],
96
+ ["addMessageIfCapacity", (params: Parameters<LocalThreadMessageQueueStore["addMessage"]>[0]) => store.addMessageIfCapacity(params, 10)],
97
+ ["addMessageAtHead", (params: Parameters<LocalThreadMessageQueueStore["addMessage"]>[0]) => store.addMessageAtHead(params)],
98
+ ])("round-trips trusted metadata through %s and processing", async (_name, add) => {
99
+ const trusted = trustedContext("input-1");
100
+ await add({
101
+ threadId: "thread", tenantId: "tenant", assistantId: "assistant",
102
+ workspaceId: "workspace", projectId: "project", content: "message", id: "input-1",
103
+ trusted_run_context: trusted, execution_mode: "followup",
104
+ });
105
+
106
+ expect((await store.getPendingMessages("thread"))[0]).toMatchObject({
107
+ trusted_run_context: trusted,
108
+ execution_mode: "followup",
109
+ });
110
+ await store.markProcessing("input-1", { model: "updated" });
111
+ expect((await store.getProcessingMessages("thread"))[0]).toMatchObject({
112
+ custom_run_config: { model: "updated" },
113
+ trusted_run_context: trusted,
114
+ execution_mode: "followup",
115
+ });
116
+ });
117
+
118
+ it("inserts capacity-accepted metadata with a persisted timestamp", async () => {
119
+ const before = Date.now();
120
+ await expect(store.addMessageIfCapacity({
121
+ threadId: "capacity", tenantId: "tenant", assistantId: "assistant",
122
+ workspaceId: "workspace", projectId: "project", content: "message", id: "capacity-input",
123
+ trusted_run_context: trustedContext("capacity-input"), execution_mode: "followup",
124
+ }, 1)).resolves.toBe(true);
125
+
126
+ const message = (await store.getPendingMessages("capacity"))[0];
127
+ expect(message).toMatchObject({
128
+ id: "capacity-input", trusted_run_context: trustedContext("capacity-input"), execution_mode: "followup",
129
+ });
130
+ expect(message.createdAt).toBeInstanceOf(Date);
131
+ expect(message.createdAt.getTime()).toBeGreaterThanOrEqual(before);
132
+ });
133
+
134
+ it.each([
135
+ ["addMessage", (params: AddMessageParams) => store.addMessage(params)],
136
+ ["addMessageIfCapacity", (params: AddMessageParams) => store.addMessageIfCapacity(params, 10)],
137
+ ["addMessageAtHead", (params: AddMessageParams) => store.addMessageAtHead(params)],
138
+ ])("rejects malformed trusted context before writing through %s", async (_name, add) => {
139
+ const params = {
140
+ threadId: "invalid", tenantId: "tenant", assistantId: "assistant", content: "message",
141
+ trusted_run_context: { projectRoom: { tenantId: "tenant" } },
142
+ } as unknown as AddMessageParams;
143
+
144
+ await expect(add(params)).rejects.toThrow("Invalid trusted agent run context");
145
+ await expect(store.getPendingMessages("invalid")).resolves.toEqual([]);
146
+ });
147
+
148
+ it.each([
149
+ ["addMessage", (params: AddMessageParams) => store.addMessage(params)],
150
+ ["addMessageIfCapacity", (params: AddMessageParams) => store.addMessageIfCapacity(params, 10)],
151
+ ["addMessageAtHead", (params: AddMessageParams) => store.addMessageAtHead(params)],
152
+ ])("rejects invalid execution mode before writing through %s", async (_name, add) => {
153
+ const params: AddMessageParams = {
154
+ threadId: "invalid-mode", tenantId: "tenant", assistantId: "assistant", content: "message",
155
+ execution_mode: "steer" as unknown as AddMessageParams["execution_mode"],
156
+ };
157
+
158
+ await expect(add(params)).rejects.toThrow("Invalid queued execution mode");
159
+ await expect(store.getPendingMessages("invalid-mode")).resolves.toEqual([]);
160
+ });
161
+
162
+ it("upgrades and reopens an existing queue table", async () => {
163
+ closeDatabase();
164
+ dbPath = path.join(tmpdir(), `lt-queue-legacy-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
165
+ db = await initDatabase({ dbPath });
166
+ db.exec(`CREATE TABLE lt_thread_message_queue (
167
+ id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
168
+ assistant_id TEXT NOT NULL, workspace_id TEXT, project_id TEXT,
169
+ message_content TEXT NOT NULL, message_type TEXT NOT NULL DEFAULT 'human',
170
+ sequence_order INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL DEFAULT 0,
171
+ status TEXT NOT NULL DEFAULT 'pending', command TEXT, custom_run_config TEXT,
172
+ created_at TEXT NOT NULL
173
+ )`);
174
+ db.save();
175
+ closeDatabase();
176
+
177
+ db = await initDatabase({ dbPath });
178
+ store = new LocalThreadMessageQueueStore(db);
179
+ await store.addMessage({
180
+ threadId: "thread", tenantId: "tenant", assistantId: "assistant",
181
+ workspaceId: "workspace", projectId: "project", content: "message", id: "input-1",
182
+ trusted_run_context: trustedContext("input-1"), execution_mode: "followup",
183
+ });
184
+
185
+ const columns = db.prepare("PRAGMA table_info(lt_thread_message_queue)").all() as Array<{ name: string }>;
186
+ expect(columns.map(({ name }) => name)).toEqual(expect.arrayContaining(["trusted_run_context", "execution_mode"]));
187
+ expect((await store.getPendingMessages("thread"))[0].execution_mode).toBe("followup");
188
+ expect(() => db.prepare("UPDATE lt_thread_message_queue SET execution_mode = 'hostile'").run()).toThrow();
189
+ });
190
+
191
+ it("rejects hostile trusted JSON read from storage", async () => {
192
+ db.prepare(`INSERT INTO lt_thread_message_queue
193
+ (id, thread_id, tenant_id, assistant_id, message_content, message_type, sequence_order,
194
+ priority, status, trusted_run_context, execution_mode, created_at)
195
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
196
+ .run("hostile", "thread", "tenant", "assistant", JSON.stringify("message"), "human", 1,
197
+ 0, "pending", JSON.stringify({ projectRoom: { tenantId: "tenant" } }), "followup", new Date().toISOString());
198
+
199
+ await expect(store.getPendingMessages("thread")).rejects.toThrow("Invalid trusted agent run context");
200
+ });
78
201
  });
@@ -20,6 +20,10 @@ import { LocalThreadStore } from "./stores/LocalThreadStore";
20
20
  import { LocalAssistantStore } from "./stores/LocalAssistantStore";
21
21
  import { LocalWorkspaceStore } from "./stores/LocalWorkspaceStore";
22
22
  import { LocalProjectStore } from "./stores/LocalProjectStore";
23
+ import { LocalProjectRoomStore } from "./stores/LocalProjectRoomStore";
24
+ import { LocalProjectMembershipStore } from "./stores/LocalProjectMembershipStore";
25
+ import { LocalProjectBotMembershipStore } from "./stores/LocalProjectBotMembershipStore";
26
+ import { LocalProjectRoomMessageStore } from "./stores/LocalProjectRoomMessageStore";
23
27
  import { LocalUserStore } from "./stores/LocalUserStore";
24
28
  import { LocalTenantStore } from "./stores/LocalTenantStore";
25
29
  import { LocalUserTenantLinkStore } from "./stores/LocalUserTenantLinkStore";
@@ -66,6 +70,10 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
66
70
  assistant: new LocalAssistantStore(db),
67
71
  workspace: new LocalWorkspaceStore(db),
68
72
  project: new LocalProjectStore(db),
73
+ projectRoom: new LocalProjectRoomStore(db),
74
+ projectMembership: new LocalProjectMembershipStore(db),
75
+ projectBotMembership: new LocalProjectBotMembershipStore(db),
76
+ projectRoomMessage: new LocalProjectRoomMessageStore(db),
69
77
  user: new LocalUserStore(db),
70
78
  tenant: new LocalTenantStore(db),
71
79
  userTenantLink: new LocalUserTenantLinkStore(db),
package/src/index.ts CHANGED
@@ -31,6 +31,10 @@ export { LocalThreadStore } from "./stores/LocalThreadStore";
31
31
  export { LocalAssistantStore } from "./stores/LocalAssistantStore";
32
32
  export { LocalWorkspaceStore } from "./stores/LocalWorkspaceStore";
33
33
  export { LocalProjectStore } from "./stores/LocalProjectStore";
34
+ export { LocalProjectRoomStore } from "./stores/LocalProjectRoomStore";
35
+ export { LocalProjectMembershipStore, DuplicateProjectMembershipError, ProjectMembershipIdConflictError } from "./stores/LocalProjectMembershipStore";
36
+ export { LocalProjectBotMembershipStore, ProjectBotMembershipIdConflictError } from "./stores/LocalProjectBotMembershipStore";
37
+ export { LocalProjectRoomMessageStore, ProjectRoomMessageIdConflictError, DuplicateProjectRoomMessageIdempotencyKeyError } from "./stores/LocalProjectRoomMessageStore";
34
38
  export { LocalUserStore } from "./stores/LocalUserStore";
35
39
  export { LocalTenantStore } from "./stores/LocalTenantStore";
36
40
  export { LocalUserTenantLinkStore } from "./stores/LocalUserTenantLinkStore";
@@ -84,7 +84,7 @@ export class MigrationManager {
84
84
  this.db.run(
85
85
  `INSERT INTO lattice_schema_migrations (store_name, name, version, applied_at)
86
86
  VALUES (?, ?, ?, ?)`,
87
- [this.storeName, m.name, m.version, new Date().toISOString()]
87
+ this.storeName, m.name, m.version, new Date().toISOString()
88
88
  );
89
89
  }
90
90
  }
@@ -3,13 +3,14 @@
3
3
  */
4
4
 
5
5
  import { DatabaseWrapper } from "../database";
6
- import type {
7
- Binding,
8
- BindingRegistry,
9
- CreateBindingInput,
6
+ import {
7
+ DuplicateChannelBindingSubjectError,
8
+ type Binding, type BindingListParams, type BindingMutablePatch,
9
+ type BindingRegistry, type CreateBindingInput,
10
10
  } from "@axiom-lattice/protocols";
11
11
  import { ensureTable, nowISO, parseISO } from "../database";
12
12
  import { randomUUID } from "crypto";
13
+ import { ensureChannelBindingSubjectIndex } from "./room-channel-binding-index";
13
14
 
14
15
  const DDL = `
15
16
  CREATE TABLE IF NOT EXISTS lt_channel_bindings (
@@ -29,7 +30,6 @@ CREATE TABLE IF NOT EXISTS lt_channel_bindings (
29
30
  created_at TEXT NOT NULL,
30
31
  updated_at TEXT NOT NULL
31
32
  );
32
- CREATE INDEX IF NOT EXISTS idx_lt_cb_resolve ON lt_channel_bindings(channel, sender_id, channel_installation_id, tenant_id);
33
33
  `;
34
34
 
35
35
  interface BindingRow {
@@ -56,6 +56,23 @@ export class LocalChannelBindingStore implements BindingRegistry {
56
56
  constructor(db: DatabaseWrapper) {
57
57
  this.db = db;
58
58
  ensureTable(db, DDL);
59
+ ensureChannelBindingSubjectIndex(db);
60
+ }
61
+
62
+ async findById(tenantId: string, id: string): Promise<Binding | null> {
63
+ const row = this.db.prepare(
64
+ "SELECT * FROM lt_channel_bindings WHERE id = ? AND tenant_id = ?",
65
+ ).get(id, tenantId) as unknown as BindingRow | undefined;
66
+ return row ? mapRowToBinding(row) : null;
67
+ }
68
+
69
+ async findBySubject(params: {
70
+ tenantId: string; channel: string; channelInstallationId: string; senderId: string;
71
+ }): Promise<Binding | null> {
72
+ const row = this.db.prepare(`SELECT * FROM lt_channel_bindings
73
+ WHERE tenant_id = ? AND channel = ? AND channel_installation_id = ? AND sender_id = ? LIMIT 1`)
74
+ .get(params.tenantId, params.channel, params.channelInstallationId, params.senderId) as unknown as BindingRow | undefined;
75
+ return row ? mapRowToBinding(row) : null;
59
76
  }
60
77
 
61
78
  async resolve(params: {
@@ -76,31 +93,34 @@ export class LocalChannelBindingStore implements BindingRegistry {
76
93
  const id = randomUUID();
77
94
  const now = nowISO();
78
95
 
79
- this.db.prepare(
80
- `INSERT INTO lt_channel_bindings
81
- (id, channel, channel_installation_id, tenant_id, sender_id, agent_id,
82
- thread_mode, sender_display_name, sender_metadata, workspace_id, project_id, created_at, updated_at)
83
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
84
- ).run(
85
- id, input.channel, input.channelInstallationId, input.tenantId,
86
- input.senderId, input.agentId, input.threadMode || "fixed",
87
- input.senderDisplayName || null,
88
- input.senderMetadata ? JSON.stringify(input.senderMetadata) : null,
89
- input.workspaceId || null, input.projectId || null,
90
- now, now,
91
- );
96
+ try {
97
+ this.db.prepare(`INSERT INTO lt_channel_bindings
98
+ (id, channel, channel_installation_id, tenant_id, sender_id, agent_id,
99
+ thread_id, thread_mode, sender_display_name, sender_metadata, workspace_id, project_id, enabled, created_at, updated_at)
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
101
+ ).run(
102
+ id, input.channel, input.channelInstallationId, input.tenantId,
103
+ input.senderId, input.agentId, input.threadId || null, input.threadMode || "fixed",
104
+ input.senderDisplayName || null,
105
+ input.senderMetadata ? JSON.stringify(input.senderMetadata) : null,
106
+ input.workspaceId || null, input.projectId || null, (input.enabled ?? true) ? 1 : 0,
107
+ now, now,
108
+ );
109
+ } catch (error) {
110
+ if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) {
111
+ throw new DuplicateChannelBindingSubjectError();
112
+ }
113
+ throw error;
114
+ }
92
115
 
93
116
  return (await this.getById(id))!;
94
117
  }
95
118
 
96
- async update(id: string, patch: Partial<Binding>): Promise<Binding> {
97
- const existing = await this.getById(id);
119
+ async update(tenantId: string, id: string, patch: BindingMutablePatch): Promise<Binding> {
120
+ const existing = await this.findById(tenantId, id);
98
121
  if (!existing) throw new Error(`Binding ${id} not found`);
99
122
 
100
123
  const updated = {
101
- channel: patch.channel ?? existing.channel,
102
- channelInstallationId: patch.channelInstallationId ?? existing.channelInstallationId,
103
- senderId: patch.senderId ?? existing.senderId,
104
124
  agentId: patch.agentId ?? existing.agentId,
105
125
  threadId: patch.threadId !== undefined ? patch.threadId : existing.threadId,
106
126
  workspaceId: patch.workspaceId !== undefined ? patch.workspaceId : existing.workspaceId,
@@ -115,39 +135,38 @@ export class LocalChannelBindingStore implements BindingRegistry {
115
135
 
116
136
  this.db.prepare(
117
137
  `UPDATE lt_channel_bindings SET
118
- channel = ?, channel_installation_id = ?, sender_id = ?, agent_id = ?,
119
- thread_id = ?, workspace_id = ?, project_id = ?, thread_mode = ?,
138
+ agent_id = ?, thread_id = ?, workspace_id = ?, project_id = ?, thread_mode = ?,
120
139
  sender_display_name = ?, sender_metadata = ?, enabled = ?, updated_at = ?
121
- WHERE id = ?`,
140
+ WHERE id = ? AND tenant_id = ?`,
122
141
  ).run(
123
- updated.channel, updated.channelInstallationId, updated.senderId,
124
142
  updated.agentId, updated.threadId || null, updated.workspaceId || null,
125
143
  updated.projectId || null, updated.threadMode, updated.senderDisplayName || null,
126
144
  updated.senderMetadata ? JSON.stringify(updated.senderMetadata) : null,
127
- updated.enabled ? 1 : 0, now, id,
145
+ updated.enabled ? 1 : 0, now, id, tenantId,
128
146
  );
129
147
 
130
- return (await this.getById(id))!;
148
+ return (await this.findById(tenantId, id))!;
131
149
  }
132
150
 
133
- async delete(id: string): Promise<void> {
134
- this.db.prepare(`DELETE FROM lt_channel_bindings WHERE id = ?`).run(id);
151
+ async delete(tenantId: string, id: string): Promise<void> {
152
+ this.db.prepare(`DELETE FROM lt_channel_bindings WHERE id = ? AND tenant_id = ?`).run(id, tenantId);
135
153
  }
136
154
 
137
- async list(params: {
138
- channel?: string;
139
- agentId?: string;
140
- tenantId: string;
141
- channelInstallationId?: string;
142
- limit?: number;
143
- offset?: number;
144
- }): Promise<Binding[]> {
155
+ async list(params: BindingListParams): Promise<Binding[]> {
145
156
  const conditions: string[] = ["tenant_id = ?"];
146
157
  const values: unknown[] = [params.tenantId];
147
158
 
148
159
  if (params.channel) { conditions.push("channel = ?"); values.push(params.channel); }
149
160
  if (params.agentId) { conditions.push("agent_id = ?"); values.push(params.agentId); }
150
161
  if (params.channelInstallationId) { conditions.push("channel_installation_id = ?"); values.push(params.channelInstallationId); }
162
+ if (params.excludeChannels?.length) {
163
+ conditions.push(`channel NOT IN (${params.excludeChannels.map(() => "?").join(", ")})`);
164
+ values.push(...params.excludeChannels);
165
+ }
166
+ for (const prefix of params.excludeInstallationIdPrefixes ?? []) {
167
+ conditions.push("substr(channel_installation_id, 1, length(?)) <> ?");
168
+ values.push(prefix, prefix);
169
+ }
151
170
 
152
171
  const limit = params.limit ?? 50;
153
172
  const offset = params.offset ?? 0;
@@ -163,7 +182,16 @@ export class LocalChannelBindingStore implements BindingRegistry {
163
182
  return rows.map(mapRowToBinding);
164
183
  }
165
184
 
166
- async import(bindings: CreateBindingInput[]): Promise<Binding[]> {
185
+ async import(tenantId: string, bindings: CreateBindingInput[]): Promise<Binding[]> {
186
+ if (bindings.some((binding) => binding.channel === "room")) {
187
+ throw new Error("Room bindings cannot be imported through the public store API");
188
+ }
189
+ if (bindings.some((binding) => binding.channelInstallationId.startsWith("room-internal:"))) {
190
+ throw new Error("Internal bindings cannot be imported through the public store API");
191
+ }
192
+ if (bindings.some((binding) => binding.tenantId !== tenantId)) {
193
+ throw new Error("Binding import tenant mismatch");
194
+ }
167
195
  const result: Binding[] = [];
168
196
  for (const input of bindings) {
169
197
  result.push(await this.create(input));
@@ -172,7 +200,13 @@ export class LocalChannelBindingStore implements BindingRegistry {
172
200
  }
173
201
 
174
202
  async export(params: { tenantId: string }): Promise<Binding[]> {
175
- return this.list({ tenantId: params.tenantId, limit: 10000, offset: 0 });
203
+ return this.list({
204
+ tenantId: params.tenantId,
205
+ excludeChannels: ["room"],
206
+ excludeInstallationIdPrefixes: ["room-internal:"],
207
+ limit: 10000,
208
+ offset: 0,
209
+ });
176
210
  }
177
211
 
178
212
  private async getById(id: string): Promise<Binding | null> {
@@ -7,7 +7,7 @@ import type {
7
7
  ChannelInstallationStore,
8
8
  ChannelInstallation,
9
9
  ChannelInstallationType,
10
- CreateChannelInstallationRequest,
10
+ CreateChannelInstallationInput,
11
11
  UpdateChannelInstallationRequest,
12
12
  } from "@axiom-lattice/protocols";
13
13
  import { ensureTable, nowISO, parseISO } from "../database";
@@ -92,7 +92,7 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
92
92
  async createInstallation(
93
93
  tenantId: string,
94
94
  installationId: string,
95
- data: CreateChannelInstallationRequest,
95
+ data: CreateChannelInstallationInput,
96
96
  ): Promise<ChannelInstallation> {
97
97
  const now = nowISO();
98
98
 
@@ -114,7 +114,7 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
114
114
  tenantId,
115
115
  channel: data.channel,
116
116
  name: data.name,
117
- config: data.config,
117
+ config: { ...data.config },
118
118
  enabled: data.enabled !== false,
119
119
  fallbackAgentId: data.fallbackAgentId,
120
120
  rejectWhenNoBinding: data.rejectWhenNoBinding ?? false,
@@ -129,7 +129,7 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
129
129
  updates: UpdateChannelInstallationRequest,
130
130
  ): Promise<ChannelInstallation | null> {
131
131
  const existing = await this.getInstallationById(installationId);
132
- if (!existing) return null;
132
+ if (!existing || existing.tenantId !== tenantId) return null;
133
133
 
134
134
  const setClauses: string[] = [];
135
135
  const values: unknown[] = [];
@@ -145,10 +145,11 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
145
145
  const now = nowISO();
146
146
  setClauses.push("updated_at = ?");
147
147
  values.push(now);
148
+ values.push(tenantId);
148
149
  values.push(installationId);
149
150
 
150
151
  this.db.prepare(
151
- `UPDATE lt_channel_installations SET ${setClauses.join(", ")} WHERE id = ?`,
152
+ `UPDATE lt_channel_installations SET ${setClauses.join(", ")} WHERE tenant_id = ? AND id = ?`,
152
153
  ).run(...values);
153
154
 
154
155
  return this.getInstallationById(installationId);