@axiom-lattice/local-stores 3.1.0 → 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.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.d.mts +193 -22
  4. package/dist/index.d.ts +193 -22
  5. package/dist/index.js +1482 -242
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1479 -242
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/LocalCapabilityBundleStore.test.ts +216 -0
  11. package/src/__tests__/LocalChannelBindingStore.test.ts +183 -0
  12. package/src/__tests__/LocalChannelInstallationStore.test.ts +23 -0
  13. package/src/__tests__/LocalProjectRoomStores.test.ts +356 -0
  14. package/src/__tests__/LocalProjectStore.test.ts +39 -2
  15. package/src/__tests__/LocalTaskStore.test.ts +58 -0
  16. package/src/__tests__/LocalTaskWorkItemStore.test.ts +199 -0
  17. package/src/__tests__/LocalThreadMessageQueueStore.test.ts +201 -0
  18. package/src/createLocalStoreConfig.ts +10 -0
  19. package/src/index.ts +5 -0
  20. package/src/migrations/migration.ts +1 -1
  21. package/src/stores/LocalCapabilityBundleStore.ts +53 -0
  22. package/src/stores/LocalChannelBindingStore.ts +75 -41
  23. package/src/stores/LocalChannelInstallationStore.ts +6 -5
  24. package/src/stores/LocalProjectBotMembershipStore.ts +105 -0
  25. package/src/stores/LocalProjectMembershipStore.ts +72 -0
  26. package/src/stores/LocalProjectRoomMessageStore.ts +63 -0
  27. package/src/stores/LocalProjectRoomStore.ts +37 -0
  28. package/src/stores/LocalProjectStore.ts +67 -14
  29. package/src/stores/LocalTaskStore.ts +82 -2
  30. package/src/stores/LocalTaskWorkItemStore.ts +136 -1
  31. package/src/stores/LocalThreadMessageQueueStore.ts +136 -28
  32. package/src/stores/project-room-schema.ts +243 -0
  33. package/src/stores/room-channel-binding-index.ts +60 -0
@@ -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(),
@@ -212,4 +240,175 @@ describe("LocalTaskWorkItemStore", () => {
212
240
  unlinkSync(dbPath);
213
241
  }
214
242
  });
243
+
244
+ it("queries bounded newest pending execution results with scope and reconciliation filtering", async () => {
245
+ const dbPath = path.join(
246
+ tmpdir(),
247
+ `lt-task-work-item-pending-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
248
+ );
249
+ try {
250
+ const db = await initDatabase({ dbPath });
251
+ const store = new LocalTaskWorkItemStore(db);
252
+ const pending = await Promise.all(["a", "b", "c"].map((suffix) => store.createIfAbsentByEventKey({
253
+ tenantId: "tenant-1", taskId: "task-1", eventKey: `execution-result:${suffix}`,
254
+ action: "execution_result", actor: "agent:worker",
255
+ })));
256
+ await store.create({
257
+ tenantId: "tenant-1", taskId: "task-1", action: "execution_result", actor: "agent:worker",
258
+ });
259
+ await store.createIfAbsentByEventKey({
260
+ tenantId: "tenant-1", taskId: "task-1", eventKey: " ",
261
+ action: "execution_result", actor: "agent:worker",
262
+ });
263
+ for (const eventKey of [
264
+ "\t", "\n", "wrong-prefix:value", "execution-result:",
265
+ " execution-result:value", "execution-result: value", "execution-result:value\t",
266
+ "execution-result:non-breaking\u00a0space", "execution-result:next-line\u0085",
267
+ "execution-result:em-space\u2003", "execution-result:invalid/punctuation",
268
+ ]) {
269
+ await store.createIfAbsentByEventKey({
270
+ tenantId: "tenant-1", taskId: "task-1", eventKey,
271
+ action: "execution_result", actor: "agent:worker",
272
+ });
273
+ }
274
+ await store.createIfAbsentByEventKey({
275
+ tenantId: "tenant-1", taskId: "task-2", eventKey: "execution-result:other-task",
276
+ action: "execution_result", actor: "agent:worker",
277
+ });
278
+ await store.createIfAbsentByEventKey({
279
+ tenantId: "tenant-2", taskId: "task-1", eventKey: "execution-result:other-tenant",
280
+ action: "execution_result", actor: "agent:worker",
281
+ });
282
+ await store.createIfAbsentByEventKey({
283
+ tenantId: "tenant-1", taskId: "task-1",
284
+ eventKey: "execution-reconciled:execution-result:b", action: "execution_reconciled",
285
+ actor: "agent:owner", detail: { executionResultId: "execution-result:b" },
286
+ });
287
+ db.prepare("UPDATE lt_task_work_items SET created_at = ? WHERE action = ?")
288
+ .run("2026-01-01T00:00:00.000Z", "execution_result");
289
+
290
+ const result = await store.listPendingExecutionResults({
291
+ tenantId: "tenant-1", taskId: "task-1", limit: 2,
292
+ });
293
+ const expectedIds = pending
294
+ .filter((item) => item.eventKey !== "execution-result:b")
295
+ .map((item) => item.id)
296
+ .sort((left, right) => right.localeCompare(left))
297
+ .slice(0, 2);
298
+
299
+ expect(result.map((item) => item.id)).toEqual(expectedIds);
300
+ } finally {
301
+ closeDatabase();
302
+ unlinkSync(dbPath);
303
+ }
304
+ });
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
+ });
345
+ it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1, 1_001])(
346
+ "rejects invalid pending execution result limit %s",
347
+ async (limit) => {
348
+ const db = {
349
+ exec: jest.fn(), prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
350
+ } as unknown as DatabaseWrapper;
351
+ const store = new LocalTaskWorkItemStore(db);
352
+ (db.prepare as jest.Mock).mockClear();
353
+
354
+ await expect(store.listPendingExecutionResults({
355
+ tenantId: "tenant-1", taskId: "task-1", limit,
356
+ })).rejects.toMatchObject({ name: "RangeError", code: "INVALID_LIMIT" });
357
+ expect(db.prepare).not.toHaveBeenCalled();
358
+ },
359
+ );
360
+
361
+ it("returns no pending execution results for limit zero without querying", async () => {
362
+ const db = {
363
+ exec: jest.fn(), prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
364
+ } as unknown as DatabaseWrapper;
365
+ const store = new LocalTaskWorkItemStore(db);
366
+ (db.prepare as jest.Mock).mockClear();
367
+
368
+ await expect(store.listPendingExecutionResults({
369
+ tenantId: "tenant-1", taskId: "task-1", limit: 0,
370
+ })).resolves.toEqual([]);
371
+ expect(db.prepare).not.toHaveBeenCalled();
372
+ });
373
+
374
+ it("creates indexes supporting ordered pending and reconciliation lookups", () => {
375
+ const exec = jest.fn();
376
+ const db = {
377
+ exec, prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
378
+ } as unknown as DatabaseWrapper;
379
+
380
+ new LocalTaskWorkItemStore(db);
381
+
382
+ expect(exec).toHaveBeenCalledWith(expect.stringContaining(
383
+ "(tenant_id, task_id, action, created_at DESC, id DESC)",
384
+ ));
385
+ expect(exec).toHaveBeenCalledWith(expect.stringMatching(
386
+ /json_extract\(detail, '\$\.executionResultId'\)[\s\S]+WHERE action = 'execution_reconciled'/,
387
+ ));
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
+ });
215
414
  });
@@ -0,0 +1,201 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import path from "path";
3
+ import { tmpdir } from "os";
4
+ import { closeDatabase, initDatabase, type DatabaseWrapper } from "../database";
5
+ import { LocalThreadMessageQueueStore } from "../stores/LocalThreadMessageQueueStore";
6
+ import type { AddMessageParams } from "@axiom-lattice/core";
7
+
8
+ describe("LocalThreadMessageQueueStore scope isolation", () => {
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
+ }
23
+
24
+ beforeEach(async () => {
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);
28
+ });
29
+
30
+ afterEach(() => closeDatabase());
31
+
32
+ it("keeps projectless reads and mutations isolated from a project sharing the thread", async () => {
33
+ const base = { threadId: "shared", tenantId: "tenant", assistantId: "assistant", workspaceId: "workspace" };
34
+ const projectless = await store.addMessage({ ...base, content: "projectless" });
35
+ const projected = await store.addMessage({ ...base, projectId: "project", content: "project" });
36
+ const scope = { tenantId: "tenant", assistantId: "assistant", workspaceId: "workspace", projectId: undefined };
37
+
38
+ expect((await store.getPendingMessages("shared", scope)).map((message) => message.id)).toEqual([projectless.id]);
39
+ expect(await store.getQueueSize("shared", scope)).toBe(1);
40
+ await store.markProcessing(projectless.id, undefined, scope);
41
+ expect((await store.getProcessingMessages("shared", scope)).map((message) => message.id)).toEqual([projectless.id]);
42
+ expect(await store.resetProcessingToPending("shared", scope)).toBe(1);
43
+ expect(await store.removeMessage(projected.id, scope)).toBe(false);
44
+ await store.clearMessages("shared", scope);
45
+ expect((await store.getPendingMessages("shared")).map((message) => message.id)).toEqual([projected.id]);
46
+ });
47
+
48
+ it("preserves nullable scope identity when discovering persisted unscoped rows", async () => {
49
+ const message = await store.addMessage({
50
+ threadId: "legacy",
51
+ tenantId: "tenant",
52
+ assistantId: "assistant",
53
+ content: "legacy",
54
+ });
55
+
56
+ const scope = { tenantId: "tenant", assistantId: "assistant", workspaceId: null, projectId: null };
57
+ expect(await store.getThreadsWithPendingMessages()).toEqual([{
58
+ tenantId: "tenant",
59
+ assistantId: "assistant",
60
+ threadId: "legacy",
61
+ workspaceId: null,
62
+ projectId: null,
63
+ }]);
64
+ expect(await store.getPendingMessages("legacy", scope)).toHaveLength(1);
65
+ await store.markProcessing(message.id, undefined, scope);
66
+ expect(await store.getProcessingMessages("legacy", scope)).toHaveLength(1);
67
+ expect(await store.removeMessage(message.id, scope)).toBe(true);
68
+ expect(await store.getProcessingMessages("legacy", scope)).toHaveLength(0);
69
+ });
70
+
71
+ it("atomically accepts no more than the bounded number of messages", async () => {
72
+ const params = {
73
+ threadId: "bounded",
74
+ tenantId: "tenant",
75
+ assistantId: "assistant",
76
+ workspaceId: null,
77
+ projectId: null,
78
+ content: "message",
79
+ };
80
+
81
+ const accepted = await Promise.all(
82
+ Array.from({ length: 5 }, (_, index) => store.addMessageIfCapacity({ ...params, id: `msg-${index}` }, 2)),
83
+ );
84
+
85
+ expect(accepted.filter(Boolean)).toHaveLength(2);
86
+ expect(await store.getQueueSize("bounded", {
87
+ tenantId: "tenant",
88
+ assistantId: "assistant",
89
+ workspaceId: null,
90
+ projectId: null,
91
+ })).toBe(2);
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
+ });
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";
@@ -37,6 +41,7 @@ import { LocalSkillStore } from "./stores/LocalSkillStore";
37
41
  import { LocalScheduleStorage } from "./stores/LocalScheduleStorage";
38
42
  import { LocalTaskStore } from "./stores/LocalTaskStore";
39
43
  import { LocalTaskWorkItemStore } from "./stores/LocalTaskWorkItemStore";
44
+ import { LocalCapabilityBundleStore } from "./stores/LocalCapabilityBundleStore";
40
45
 
41
46
  export interface LocalStoreConfigOptions {
42
47
  /**
@@ -65,6 +70,10 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
65
70
  assistant: new LocalAssistantStore(db),
66
71
  workspace: new LocalWorkspaceStore(db),
67
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),
68
77
  user: new LocalUserStore(db),
69
78
  tenant: new LocalTenantStore(db),
70
79
  userTenantLink: new LocalUserTenantLinkStore(db),
@@ -82,6 +91,7 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
82
91
  schedule: new LocalScheduleStorage(db),
83
92
  task: new LocalTaskStore(db),
84
93
  taskWorkItem: new LocalTaskWorkItemStore(db),
94
+ capabilityBundle: new LocalCapabilityBundleStore(db),
85
95
  checkpoint: SqliteSaver.fromConnString(dbPath),
86
96
  };
87
97
  }
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";
@@ -48,4 +52,5 @@ export { LocalSkillStore } from "./stores/LocalSkillStore";
48
52
  export { LocalScheduleStorage } from "./stores/LocalScheduleStorage";
49
53
  export { LocalTaskStore } from "./stores/LocalTaskStore";
50
54
  export { LocalTaskWorkItemStore } from "./stores/LocalTaskWorkItemStore";
55
+ export { LocalCapabilityBundleStore } from "./stores/LocalCapabilityBundleStore";
51
56
  export { InMemoryConversationStore } from "./stores/InMemoryConversationStore";
@@ -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
  }
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from "crypto";
2
+ import type { CapabilityBundle, CapabilityBundleDeleteResult, CapabilityBundleStore, InternalCreateCapabilityBundleInput, InternalUpdateCapabilityBundleInput } from "@axiom-lattice/protocols";
3
+ import { DatabaseWrapper, ensureTable, nowISO, parseISO } from "../database";
4
+
5
+ const DDL = `CREATE TABLE IF NOT EXISTS lt_capability_bundles (id TEXT NOT NULL, tenant_id TEXT NOT NULL, bundle_key TEXT NOT NULL, name TEXT NOT NULL, description TEXT, capabilities TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, bundle_key)); CREATE INDEX IF NOT EXISTS idx_lt_capability_bundles_tenant ON lt_capability_bundles(tenant_id);`;
6
+ type Row = { id: string; tenant_id: string; bundle_key: string; name: string; description: string | null; capabilities: string; created_at: string; updated_at: string };
7
+ const duplicateMessage = "Capability bundle key already exists for tenant";
8
+ function map(row: Row): CapabilityBundle { return { id: row.id, tenantId: row.tenant_id, key: row.bundle_key, name: row.name, description: row.description ?? undefined, capabilities: JSON.parse(row.capabilities), createdAt: parseISO(row.created_at).toISOString(), updatedAt: parseISO(row.updated_at).toISOString() }; }
9
+ function isConstraint(error: unknown): boolean { return error instanceof Error && /UNIQUE constraint failed/.test(error.message); }
10
+ function nextTimestamp(previous?: string): string { const now = Date.now(); const previousTime = previous ? Date.parse(previous) : Number.NaN; return new Date(Math.max(now, Number.isFinite(previousTime) ? previousTime + 1 : now)).toISOString(); }
11
+
12
+ /**
13
+ * Persists tenant-scoped capability bundles in a shared local sql.js database.
14
+ *
15
+ * The constructor creates the required table and index idempotently. Database
16
+ * lifecycle remains owned by the caller through `initDatabase` and
17
+ * `closeDatabase`.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const db = await initDatabase({ dbPath: "./lattice.db" });
22
+ * const store = new LocalCapabilityBundleStore(db);
23
+ * const bundles = await store.listByTenant("tenant-1");
24
+ * ```
25
+ */
26
+ export class LocalCapabilityBundleStore implements CapabilityBundleStore {
27
+ /**
28
+ * Creates a capability bundle store over an initialized sql.js database.
29
+ *
30
+ * @param db - Shared local database wrapper used for persistence.
31
+ */
32
+ constructor(private readonly db: DatabaseWrapper) { ensureTable(db, DDL); }
33
+ async listByTenant(tenantId: string): Promise<CapabilityBundle[]> { return (this.db.prepare("SELECT * FROM lt_capability_bundles WHERE tenant_id = ? ORDER BY created_at").all(tenantId) as unknown as Row[]).map(map); }
34
+ async getById(tenantId: string, id: string): Promise<CapabilityBundle | null> { const row = this.db.prepare("SELECT * FROM lt_capability_bundles WHERE tenant_id = ? AND id = ?").get(tenantId, id) as unknown as Row | undefined; return row ? map(row) : null; }
35
+ async getManyByIds(tenantId: string, ids: string[]): Promise<CapabilityBundle[]> { return ids.flatMap((id) => { const row = this.db.prepare("SELECT * FROM lt_capability_bundles WHERE tenant_id = ? AND id = ?").get(tenantId, id) as unknown as Row | undefined; return row ? [map(row)] : []; }); }
36
+ async create(tenantId: string, input: InternalCreateCapabilityBundleInput): Promise<CapabilityBundle> { const now = nextTimestamp(); const id = randomUUID(); try { this.db.prepare("INSERT INTO lt_capability_bundles (id, tenant_id, bundle_key, name, description, capabilities, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(id, tenantId, input.key, input.name, input.description ?? null, JSON.stringify(input.capabilities), now, now); } catch (error) { if (isConstraint(error)) throw new Error(duplicateMessage); throw error; } return (await this.getById(tenantId, id)) as CapabilityBundle; }
37
+ async update(tenantId: string, id: string, input: InternalUpdateCapabilityBundleInput): Promise<CapabilityBundle | { status: "conflict" } | null> { const existing = await this.getById(tenantId, id); if (!existing) return null; if (input.expectedUpdatedAt !== undefined && input.expectedUpdatedAt !== existing.updatedAt) return { status: "conflict" }; const fields: string[] = []; const values: unknown[] = []; const add = (field: string, value: unknown): void => { fields.push(`${field} = ?`); values.push(value); }; if (input.name !== undefined) add("name", input.name); if (Object.prototype.hasOwnProperty.call(input, "description")) add("description", input.description ?? null); if (input.capabilities !== undefined) add("capabilities", JSON.stringify(input.capabilities)); if (!fields.length) return existing; const now = nextTimestamp(existing.updatedAt); fields.push("updated_at = ?"); values.push(now, tenantId, id); try { if (input.expectedUpdatedAt !== undefined) { const result = this.db.prepare(`UPDATE lt_capability_bundles SET ${fields.join(", ")} WHERE tenant_id = ? AND id = ? AND updated_at = ?`).run(...values, input.expectedUpdatedAt); if (result.changes === 0) return { status: "conflict" }; } else { this.db.prepare(`UPDATE lt_capability_bundles SET ${fields.join(", ")} WHERE tenant_id = ? AND id = ?`).run(...values); } } catch (error) { if (isConstraint(error)) throw new Error(duplicateMessage); throw error; } return this.getById(tenantId, id); }
38
+ async deleteIfUnreferenced(tenantId: string, id: string): Promise<CapabilityBundleDeleteResult> {
39
+ const result = this.db.prepare(
40
+ `DELETE FROM lt_capability_bundles
41
+ WHERE tenant_id = ? AND id = ?
42
+ AND NOT EXISTS (
43
+ SELECT 1 FROM lt_projects
44
+ WHERE tenant_id = ?
45
+ AND json_type(config, '$.capabilityBundleIds') = 'array'
46
+ AND EXISTS (SELECT 1 FROM json_each(config, '$.capabilityBundleIds') WHERE value = ?)
47
+ )`,
48
+ ).run(tenantId, id, tenantId, id);
49
+ if (result.changes > 0) return "deleted";
50
+ if (await this.getById(tenantId, id)) return "in_use";
51
+ return "not_found";
52
+ }
53
+ }