@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +9 -0
- package/dist/index.d.mts +142 -14
- package/dist/index.d.ts +142 -14
- package/dist/index.js +1194 -206
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1189 -203
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalCapabilityBundleStore.test.ts +1 -2
- package/src/__tests__/LocalChannelBindingStore.test.ts +183 -0
- package/src/__tests__/LocalChannelInstallationStore.test.ts +23 -0
- package/src/__tests__/LocalProjectRoomStores.test.ts +356 -0
- package/src/__tests__/LocalTaskStore.test.ts +58 -0
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +92 -0
- package/src/__tests__/LocalThreadMessageQueueStore.test.ts +126 -3
- package/src/createLocalStoreConfig.ts +8 -0
- package/src/index.ts +4 -0
- package/src/migrations/migration.ts +1 -1
- package/src/stores/LocalChannelBindingStore.ts +75 -41
- package/src/stores/LocalChannelInstallationStore.ts +6 -5
- package/src/stores/LocalProjectBotMembershipStore.ts +105 -0
- package/src/stores/LocalProjectMembershipStore.ts +72 -0
- package/src/stores/LocalProjectRoomMessageStore.ts +63 -0
- package/src/stores/LocalProjectRoomStore.ts +37 -0
- package/src/stores/LocalTaskStore.ts +82 -2
- package/src/stores/LocalTaskWorkItemStore.ts +91 -1
- package/src/stores/LocalThreadMessageQueueStore.ts +52 -6
- package/src/stores/project-room-schema.ts +243 -0
- package/src/stores/room-channel-binding-index.ts +60 -0
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
80
|
-
`INSERT INTO lt_channel_bindings
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
97
|
-
const existing = await this.
|
|
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
|
-
|
|
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.
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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);
|