@axiom-lattice/local-stores 3.0.2 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/local-stores",
3
- "version": "3.0.2",
3
+ "version": "3.1.1",
4
4
  "description": "Local SQLite-based stores for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,8 +25,8 @@
25
25
  "@types/sql.js": "^1.4.11",
26
26
  "sql.js": "^1.14.1",
27
27
  "uuid": "^14.0.1",
28
- "@axiom-lattice/core": "4.1.0",
29
- "@axiom-lattice/protocols": "4.0.1"
28
+ "@axiom-lattice/core": "4.2.1",
29
+ "@axiom-lattice/protocols": "4.1.1"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/jest": "^29.5.14",
@@ -0,0 +1,217 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import { tmpdir } from "os";
3
+ import path from "path";
4
+
5
+ import { closeDatabase, initDatabase } from "../database";
6
+ import { LocalCapabilityBundleStore } from "../stores/LocalCapabilityBundleStore";
7
+ import { LocalProjectStore } from "../stores/LocalProjectStore";
8
+
9
+ const nextDbPath = (): string => path.join(
10
+ tmpdir(), `lt-capability-bundle-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
11
+ );
12
+ const input = {
13
+ key: "research",
14
+ name: "Research",
15
+ description: "Research tools",
16
+ capabilities: [{
17
+ id: "filesystem", type: "filesystem", name: "Filesystem", enabled: true,
18
+ config: { roots: ["/workspace"], policy: { write: false } },
19
+ }],
20
+ };
21
+
22
+ describe("LocalCapabilityBundleStore", () => {
23
+ let store: LocalCapabilityBundleStore;
24
+ let projectStore: LocalProjectStore;
25
+ let db: Awaited<ReturnType<typeof initDatabase>>;
26
+
27
+ beforeEach(async () => {
28
+ db = await initDatabase({ dbPath: nextDbPath() });
29
+ store = new LocalCapabilityBundleStore(db);
30
+ projectStore = new LocalProjectStore(db);
31
+ });
32
+ afterEach(() => closeDatabase());
33
+
34
+ it("supports tenant-scoped CRUD, bulk reads, and nested JSON round-trips", async () => {
35
+ const created = await store.create("tenant-1", input);
36
+ await store.create("tenant-2", input);
37
+ expect(created.id).toMatch(/^[0-9a-f-]{36}$/);
38
+ expect(await store.listByTenant("tenant-1")).toHaveLength(1);
39
+ expect(await store.getById("tenant-2", created.id)).toBeNull();
40
+ expect(await store.getManyByIds("tenant-1", [created.id, "missing"])).toEqual([created]);
41
+ expect((await store.getById("tenant-1", created.id))?.capabilities).toEqual(input.capabilities);
42
+
43
+ const updated = await store.update("tenant-1", created.id, {
44
+ key: "analysis", name: "Analysis", capabilities: [{ ...input.capabilities[0], config: { depth: 2 } }],
45
+ });
46
+ expect(updated?.id).toBe(created.id);
47
+ expect(updated?.createdAt).toBe(created.createdAt);
48
+ expect(Number.isNaN(Date.parse(created.createdAt))).toBe(false);
49
+ expect(Number.isNaN(Date.parse(updated?.updatedAt ?? ""))).toBe(false);
50
+ expect(Date.parse(updated?.updatedAt ?? "")).toBeGreaterThanOrEqual(Date.parse(created.createdAt));
51
+ expect(await store.update("tenant-2", created.id, { name: "Nope" })).toBeNull();
52
+ expect("delete" in store).toBe(false);
53
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("deleted");
54
+ });
55
+
56
+ it("returns defensive nested capability configuration values", async () => {
57
+ const created = await store.create("tenant-1", input);
58
+ const fetched = await store.getById("tenant-1", created.id);
59
+ const config = fetched?.capabilities[0].config as {
60
+ roots: string[];
61
+ policy: { write: boolean };
62
+ };
63
+
64
+ config.roots.push("/mutated");
65
+ config.policy.write = true;
66
+
67
+ expect((await store.getById("tenant-1", created.id))?.capabilities).toEqual(input.capabilities);
68
+ });
69
+
70
+ it("enforces tenant-local unique keys with a stable error", async () => {
71
+ await store.create("tenant-1", input);
72
+ await expect(store.create("tenant-1", input)).rejects.toThrow(
73
+ "Capability bundle key already exists for tenant",
74
+ );
75
+ await expect(store.create("tenant-2", input)).resolves.toMatchObject({ key: input.key });
76
+ });
77
+
78
+ it("advances the revision on rapid content updates", async () => {
79
+ jest.useFakeTimers().setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
80
+ try {
81
+ const created = await store.create("tenant-1", input);
82
+ const first = await store.update("tenant-1", created.id, { name: "First" });
83
+ const second = await store.update("tenant-1", created.id, { name: "Second" });
84
+
85
+ expect(Date.parse(first!.updatedAt)).toBeGreaterThan(Date.parse(created.updatedAt));
86
+ expect(Date.parse(second!.updatedAt)).toBeGreaterThan(Date.parse(first!.updatedAt));
87
+ await expect(store.update("tenant-1", created.id, { name: "Rejected" })).resolves.toMatchObject({ name: "Rejected" });
88
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
89
+ await expect(projectStore.updateCapabilityBundleIds("tenant-1", "project", [created.id], {
90
+ [created.id]: first!.updatedAt,
91
+ })).resolves.toEqual({ status: "bundle_conflict" });
92
+ } finally {
93
+ jest.useRealTimers();
94
+ }
95
+ });
96
+
97
+ it("rejects an update when its expected revision is stale", async () => {
98
+ const created = await store.create("tenant-1", input);
99
+ await store.update("tenant-1", created.id, { name: "First" });
100
+ await expect(store.update("tenant-1", created.id, { name: "Stale", expectedUpdatedAt: created.updatedAt }))
101
+ .resolves.toEqual({ status: "conflict" });
102
+ await expect(store.getById("tenant-1", created.id)).resolves.toMatchObject({ name: "First" });
103
+ });
104
+
105
+ it("checks CAS tokens for no-op updates without changing the revision", async () => {
106
+ const created = await store.create("tenant-1", input);
107
+
108
+ await expect(store.update("tenant-1", created.id, { expectedUpdatedAt: "stale" }))
109
+ .resolves.toEqual({ status: "conflict" });
110
+ await expect(store.update("tenant-1", created.id, { expectedUpdatedAt: created.updatedAt }))
111
+ .resolves.toEqual(created);
112
+ });
113
+
114
+ it("conditionally deletes bundles only when no tenant project references them", async () => {
115
+ const created = await store.create("tenant-1", input);
116
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
117
+ await projectStore.updateCapabilityBundleIds("tenant-1", "project", [created.id]);
118
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("in_use");
119
+ await projectStore.updateCapabilityBundleIds("tenant-1", "project", []);
120
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("deleted");
121
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("not_found");
122
+ });
123
+
124
+ it("rejects a stale composition after its validated bundle is deleted", async () => {
125
+ const created = await store.create("tenant-1", input);
126
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
127
+
128
+ await expect(store.getManyByIds("tenant-1", [created.id])).resolves.toHaveLength(1);
129
+ await expect(store.deleteIfUnreferenced("tenant-1", created.id)).resolves.toBe("deleted");
130
+ await expect(projectStore.updateCapabilityBundleIds("tenant-1", "project", [created.id]))
131
+ .resolves.toEqual({ status: "bundle_not_found" });
132
+ await expect(projectStore.getProjectById("tenant-1", "project"))
133
+ .resolves.toMatchObject({ config: undefined });
134
+ });
135
+
136
+ it("finds references only for the requested tenant", async () => {
137
+ const tenantOneBundle = await store.create("tenant-1", { ...input, key: "tenant-one" });
138
+ const tenantTwoBundle = await store.create("tenant-2", { ...input, key: "tenant-two" });
139
+ await projectStore.createProject("tenant-1", "workspace", "reference", { name: "Reference" });
140
+ await projectStore.updateCapabilityBundleIds("tenant-1", "reference", [tenantOneBundle.id]);
141
+ await projectStore.createProject("tenant-2", "workspace", "other", { name: "Other" });
142
+ await projectStore.updateCapabilityBundleIds("tenant-2", "other", [tenantTwoBundle.id]);
143
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", tenantOneBundle.id)).resolves.toBe(true);
144
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", tenantTwoBundle.id)).resolves.toBe(false);
145
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", "missing")).resolves.toBe(false);
146
+ });
147
+
148
+ it("treats malformed project config JSON as having no bundle reference", async () => {
149
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
150
+ db.prepare("UPDATE lt_projects SET config = ? WHERE tenant_id = ? AND id = ?")
151
+ .run("{malformed", "tenant-1", "project");
152
+
153
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", "bundle-1")).resolves.toBe(false);
154
+ });
155
+
156
+ it.each(["null", "[]", "true", "7", '"config"'])(
157
+ "treats valid non-object project config JSON %s as having no bundle reference",
158
+ async (config) => {
159
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project" });
160
+ db.prepare("UPDATE lt_projects SET config = ? WHERE tenant_id = ? AND id = ?")
161
+ .run(config, "tenant-1", "project");
162
+
163
+ await expect(projectStore.isCapabilityBundleReferenced("tenant-1", "bundle-1")).resolves.toBe(false);
164
+ },
165
+ );
166
+
167
+ it("updates only capability bundle IDs while preserving unrelated config", async () => {
168
+ await projectStore.createProject("tenant-1", "workspace", "project", { name: "Project", config: { keep: true } });
169
+ const first = await store.create("tenant-1", { ...input, key: "b1" });
170
+ const second = await store.create("tenant-1", { ...input, key: "b2" });
171
+ const updated = await projectStore.updateCapabilityBundleIds("tenant-1", "project", [first.id, second.id]);
172
+ expect(updated).toMatchObject({ status: "updated", project: { config: { keep: true, capabilityBundleIds: [first.id, second.id] } } });
173
+ await expect(projectStore.updateCapabilityBundleIds("tenant-2", "project", [])).resolves.toEqual({ status: "project_not_found" });
174
+ });
175
+
176
+ it("rejects assignment when the reviewed bundle revision is stale", async () => {
177
+ const bundle = await store.create("tenant-1", input);
178
+ await projectStore.createProject("tenant-1", "workspace", "conflict", { name: "Conflict" });
179
+
180
+ await expect(projectStore.updateCapabilityBundleIds("tenant-1", "conflict", [bundle.id], { [bundle.id]: "stale" }))
181
+ .resolves.toEqual({ status: "bundle_conflict" });
182
+ });
183
+
184
+ it("rejects capability bundle references through generic project writes", async () => {
185
+ await expect(projectStore.createProject("tenant-1", "workspace", "project", {
186
+ name: "Project", config: { capabilityBundleIds: [] },
187
+ })).rejects.toMatchObject({ code: "INVALID_BUNDLE_CONFIG" });
188
+ await projectStore.createProject("tenant-1", "workspace", "project", {
189
+ name: "Project", config: { keep: true },
190
+ });
191
+ await expect(projectStore.updateProject("tenant-1", "project", {
192
+ config: { capabilityBundleIds: [] },
193
+ })).rejects.toMatchObject({ code: "INVALID_BUNDLE_CONFIG" });
194
+ await expect(projectStore.getProjectById("tenant-1", "project"))
195
+ .resolves.toMatchObject({ config: { keep: true } });
196
+ });
197
+
198
+ it("preserves capability bundle IDs when generic create upserts an assigned project", async () => {
199
+ const bundle = await store.create("tenant-1", input);
200
+ await projectStore.createProject("tenant-1", "workspace", "project", {
201
+ name: "Original",
202
+ config: { original: true },
203
+ });
204
+ await projectStore.updateCapabilityBundleIds("tenant-1", "project", [bundle.id]);
205
+
206
+ const upserted = await projectStore.createProject("tenant-1", "workspace-2", "project", {
207
+ name: "Upserted",
208
+ config: { replacement: true },
209
+ });
210
+
211
+ expect(upserted).toMatchObject({
212
+ workspaceId: "workspace-2",
213
+ name: "Upserted",
214
+ config: { replacement: true, capabilityBundleIds: [bundle.id] },
215
+ });
216
+ });
217
+ });
@@ -8,7 +8,7 @@
8
8
  import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
9
9
  import { tmpdir } from "os";
10
10
  import path from "path";
11
- import { initDatabase, closeDatabase, ensureTable } from "../database";
11
+ import { initDatabase, closeDatabase, ensureTable, type DatabaseWrapper } from "../database";
12
12
  import { LocalEvalStore, DDL } from "../stores/LocalEvalStore";
13
13
 
14
14
  // sql.js persists to disk via DatabaseWrapper.save(), so each test needs a
@@ -21,9 +21,10 @@ const nextDbPath = () => {
21
21
 
22
22
  describe("LocalEvalStore project name uniqueness", () => {
23
23
  let store: LocalEvalStore;
24
+ let db: DatabaseWrapper;
24
25
 
25
26
  beforeEach(async () => {
26
- const db = await initDatabase({ dbPath: nextDbPath() });
27
+ db = await initDatabase({ dbPath: nextDbPath() });
27
28
  store = new LocalEvalStore(db);
28
29
  });
29
30
 
@@ -93,4 +94,42 @@ describe("LocalEvalStore project name uniqueness", () => {
93
94
  const runs = db.prepare(`SELECT project_id FROM lt_eval_runs WHERE id = 'run-1'`).get() as { project_id: string };
94
95
  expect(runs.project_id).toBe("dup-new");
95
96
  });
97
+
98
+ it("deletes project suites, cases, runs, and results without affecting another project", async () => {
99
+ await store.createProject("tenant-1", "project-delete", {
100
+ name: "delete-me", judgeModelConfig: {}, targetServerConfig: {},
101
+ });
102
+ await store.createProject("tenant-1", "project-keep", {
103
+ name: "keep-me", judgeModelConfig: {}, targetServerConfig: {},
104
+ });
105
+ await store.createSuite("tenant-1", "project-delete", "suite-delete", { name: "delete-suite" });
106
+ await store.createSuite("tenant-1", "project-keep", "suite-keep", { name: "keep-suite" });
107
+ await store.createCase("tenant-1", "suite-delete", "case-delete", {
108
+ inputMessage: "delete", steps: [], outputType: "message_content", contentAssertion: "delete",
109
+ });
110
+ await store.createCase("tenant-1", "suite-keep", "case-keep", {
111
+ inputMessage: "keep", steps: [], outputType: "message_content", contentAssertion: "keep",
112
+ });
113
+ await store.createRun("tenant-1", "project-delete", "run-delete", { concurrency: 1, totalCases: 1 });
114
+ await store.createRun("tenant-1", "project-keep", "run-keep", { concurrency: 1, totalCases: 1 });
115
+ await store.createRunResult("tenant-1", "run-delete", "result-delete", {
116
+ suiteName: "delete-suite", caseId: "case-delete", pass: true, score: 100,
117
+ });
118
+ await store.createRunResult("tenant-1", "run-keep", "result-keep", {
119
+ suiteName: "keep-suite", caseId: "case-keep", pass: true, score: 100,
120
+ });
121
+
122
+ await expect(store.deleteProject("tenant-1", "project-delete")).resolves.toBe(true);
123
+
124
+ for (const table of ["lt_eval_projects", "lt_eval_suites", "lt_eval_cases", "lt_eval_runs", "lt_eval_run_results"]) {
125
+ const deleted = db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE id LIKE '%delete'`).get() as { count: number };
126
+ const kept = db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE id LIKE '%keep'`).get() as { count: number };
127
+ expect(deleted.count).toBe(0);
128
+ expect(kept.count).toBe(1);
129
+ }
130
+ });
131
+
132
+ it("returns false when deleting a project that does not exist", async () => {
133
+ await expect(store.deleteProject("tenant-1", "missing-project")).resolves.toBe(false);
134
+ });
96
135
  });
@@ -6,7 +6,7 @@
6
6
  * getProjectsByWorkspace supports an optional kind filter.
7
7
  */
8
8
 
9
- import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
9
+ import { describe, expect, it, beforeEach, afterEach, jest } from "@jest/globals";
10
10
  import { tmpdir } from "os";
11
11
  import path from "path";
12
12
  import { initDatabase, closeDatabase } from "../database";
@@ -19,11 +19,12 @@ const nextDbPath = () =>
19
19
 
20
20
  describe("LocalProjectStore project kind", () => {
21
21
  let store: LocalProjectStore;
22
+ let db: Awaited<ReturnType<typeof initDatabase>>;
22
23
  const tenantId = "t1";
23
24
  const workspaceId = "ws1";
24
25
 
25
26
  beforeEach(async () => {
26
- const db = await initDatabase({ dbPath: nextDbPath() });
27
+ db = await initDatabase({ dbPath: nextDbPath() });
27
28
  store = new LocalProjectStore(db);
28
29
  });
29
30
 
@@ -81,6 +82,42 @@ describe("LocalProjectStore project kind", () => {
81
82
  expect(all).toHaveLength(2);
82
83
  });
83
84
 
85
+ it("preserves capability bundle IDs during generic config replacement", async () => {
86
+ await store.createProject(tenantId, workspaceId, "reserved", { name: "Reserved", config: { keep: true } });
87
+ db.exec("UPDATE lt_projects SET config = '{\"keep\":true,\"capabilityBundleIds\":[\"bundle-1\"]}' WHERE tenant_id = 't1' AND id = 'reserved'");
88
+
89
+ const updated = await store.updateProject(tenantId, "reserved", { config: { replacement: true } });
90
+
91
+ expect(updated?.config).toEqual({ replacement: true, capabilityBundleIds: ["bundle-1"] });
92
+ });
93
+
94
+ it("preserves an assignment made between the generic read and write", async () => {
95
+ await store.createProject(tenantId, workspaceId, "interleaved", {
96
+ name: "Interleaved",
97
+ config: { keep: true },
98
+ });
99
+ const originalPrepare = db.prepare.bind(db);
100
+ let injected = false;
101
+ jest.spyOn(db, "prepare").mockImplementation((sql) => {
102
+ if (!injected && sql.startsWith("UPDATE lt_projects SET config =")) {
103
+ injected = true;
104
+ originalPrepare(
105
+ "UPDATE lt_projects SET config = json_set(COALESCE(config, '{}'), '$.capabilityBundleIds', json(?)) WHERE tenant_id = ? AND id = ?",
106
+ ).run('["bundle-latest"]', tenantId, "interleaved");
107
+ }
108
+ return originalPrepare(sql);
109
+ });
110
+
111
+ const updated = await store.updateProject(tenantId, "interleaved", {
112
+ config: { replacement: true },
113
+ });
114
+
115
+ expect(updated?.config).toEqual({
116
+ replacement: true,
117
+ capabilityBundleIds: ["bundle-latest"],
118
+ });
119
+ });
120
+
84
121
  describe("multiple training projects per tenant/workspace", () => {
85
122
  it("allows multiple training projects in the same tenant/workspace", async () => {
86
123
  await store.createProject(tenantId, workspaceId, "t1", {
@@ -212,4 +212,111 @@ describe("LocalTaskWorkItemStore", () => {
212
212
  unlinkSync(dbPath);
213
213
  }
214
214
  });
215
+
216
+ it("queries bounded newest pending execution results with scope and reconciliation filtering", async () => {
217
+ const dbPath = path.join(
218
+ tmpdir(),
219
+ `lt-task-work-item-pending-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
220
+ );
221
+ try {
222
+ const db = await initDatabase({ dbPath });
223
+ const store = new LocalTaskWorkItemStore(db);
224
+ const pending = await Promise.all(["a", "b", "c"].map((suffix) => store.createIfAbsentByEventKey({
225
+ tenantId: "tenant-1", taskId: "task-1", eventKey: `execution-result:${suffix}`,
226
+ action: "execution_result", actor: "agent:worker",
227
+ })));
228
+ await store.create({
229
+ tenantId: "tenant-1", taskId: "task-1", action: "execution_result", actor: "agent:worker",
230
+ });
231
+ await store.createIfAbsentByEventKey({
232
+ tenantId: "tenant-1", taskId: "task-1", eventKey: " ",
233
+ action: "execution_result", actor: "agent:worker",
234
+ });
235
+ for (const eventKey of [
236
+ "\t", "\n", "wrong-prefix:value", "execution-result:",
237
+ " execution-result:value", "execution-result: value", "execution-result:value\t",
238
+ "execution-result:non-breaking\u00a0space", "execution-result:next-line\u0085",
239
+ "execution-result:em-space\u2003", "execution-result:invalid/punctuation",
240
+ ]) {
241
+ await store.createIfAbsentByEventKey({
242
+ tenantId: "tenant-1", taskId: "task-1", eventKey,
243
+ action: "execution_result", actor: "agent:worker",
244
+ });
245
+ }
246
+ await store.createIfAbsentByEventKey({
247
+ tenantId: "tenant-1", taskId: "task-2", eventKey: "execution-result:other-task",
248
+ action: "execution_result", actor: "agent:worker",
249
+ });
250
+ await store.createIfAbsentByEventKey({
251
+ tenantId: "tenant-2", taskId: "task-1", eventKey: "execution-result:other-tenant",
252
+ action: "execution_result", actor: "agent:worker",
253
+ });
254
+ await store.createIfAbsentByEventKey({
255
+ tenantId: "tenant-1", taskId: "task-1",
256
+ eventKey: "execution-reconciled:execution-result:b", action: "execution_reconciled",
257
+ actor: "agent:owner", detail: { executionResultId: "execution-result:b" },
258
+ });
259
+ db.prepare("UPDATE lt_task_work_items SET created_at = ? WHERE action = ?")
260
+ .run("2026-01-01T00:00:00.000Z", "execution_result");
261
+
262
+ const result = await store.listPendingExecutionResults({
263
+ tenantId: "tenant-1", taskId: "task-1", limit: 2,
264
+ });
265
+ const expectedIds = pending
266
+ .filter((item) => item.eventKey !== "execution-result:b")
267
+ .map((item) => item.id)
268
+ .sort((left, right) => right.localeCompare(left))
269
+ .slice(0, 2);
270
+
271
+ expect(result.map((item) => item.id)).toEqual(expectedIds);
272
+ } finally {
273
+ closeDatabase();
274
+ unlinkSync(dbPath);
275
+ }
276
+ });
277
+
278
+ it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1, 1_001])(
279
+ "rejects invalid pending execution result limit %s",
280
+ async (limit) => {
281
+ const db = {
282
+ exec: jest.fn(), prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
283
+ } as unknown as DatabaseWrapper;
284
+ const store = new LocalTaskWorkItemStore(db);
285
+ (db.prepare as jest.Mock).mockClear();
286
+
287
+ await expect(store.listPendingExecutionResults({
288
+ tenantId: "tenant-1", taskId: "task-1", limit,
289
+ })).rejects.toMatchObject({ name: "RangeError", code: "INVALID_LIMIT" });
290
+ expect(db.prepare).not.toHaveBeenCalled();
291
+ },
292
+ );
293
+
294
+ it("returns no pending execution results for limit zero without querying", async () => {
295
+ const db = {
296
+ exec: jest.fn(), prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
297
+ } as unknown as DatabaseWrapper;
298
+ const store = new LocalTaskWorkItemStore(db);
299
+ (db.prepare as jest.Mock).mockClear();
300
+
301
+ await expect(store.listPendingExecutionResults({
302
+ tenantId: "tenant-1", taskId: "task-1", limit: 0,
303
+ })).resolves.toEqual([]);
304
+ expect(db.prepare).not.toHaveBeenCalled();
305
+ });
306
+
307
+ it("creates indexes supporting ordered pending and reconciliation lookups", () => {
308
+ const exec = jest.fn();
309
+ const db = {
310
+ exec, prepare: jest.fn().mockReturnValue({ all: jest.fn().mockReturnValue([]) }),
311
+ } as unknown as DatabaseWrapper;
312
+
313
+ new LocalTaskWorkItemStore(db);
314
+
315
+ expect(exec).toHaveBeenCalledWith(expect.stringContaining(
316
+ "(tenant_id, task_id, action, created_at DESC, id DESC)",
317
+ ));
318
+ expect(exec).toHaveBeenCalledWith(expect.stringMatching(
319
+ /json_extract\(detail, '\$\.executionResultId'\)[\s\S]+WHERE action = 'execution_reconciled'/,
320
+ ));
321
+ });
215
322
  });
@@ -0,0 +1,78 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";
2
+ import path from "path";
3
+ import { tmpdir } from "os";
4
+ import { closeDatabase, initDatabase } from "../database";
5
+ import { LocalThreadMessageQueueStore } from "../stores/LocalThreadMessageQueueStore";
6
+
7
+ describe("LocalThreadMessageQueueStore scope isolation", () => {
8
+ let store: LocalThreadMessageQueueStore;
9
+
10
+ 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 }));
13
+ });
14
+
15
+ afterEach(() => closeDatabase());
16
+
17
+ it("keeps projectless reads and mutations isolated from a project sharing the thread", async () => {
18
+ const base = { threadId: "shared", tenantId: "tenant", assistantId: "assistant", workspaceId: "workspace" };
19
+ const projectless = await store.addMessage({ ...base, content: "projectless" });
20
+ const projected = await store.addMessage({ ...base, projectId: "project", content: "project" });
21
+ const scope = { tenantId: "tenant", assistantId: "assistant", workspaceId: "workspace", projectId: undefined };
22
+
23
+ expect((await store.getPendingMessages("shared", scope)).map((message) => message.id)).toEqual([projectless.id]);
24
+ expect(await store.getQueueSize("shared", scope)).toBe(1);
25
+ await store.markProcessing(projectless.id, undefined, scope);
26
+ expect((await store.getProcessingMessages("shared", scope)).map((message) => message.id)).toEqual([projectless.id]);
27
+ expect(await store.resetProcessingToPending("shared", scope)).toBe(1);
28
+ expect(await store.removeMessage(projected.id, scope)).toBe(false);
29
+ await store.clearMessages("shared", scope);
30
+ expect((await store.getPendingMessages("shared")).map((message) => message.id)).toEqual([projected.id]);
31
+ });
32
+
33
+ it("preserves nullable scope identity when discovering persisted unscoped rows", async () => {
34
+ const message = await store.addMessage({
35
+ threadId: "legacy",
36
+ tenantId: "tenant",
37
+ assistantId: "assistant",
38
+ content: "legacy",
39
+ });
40
+
41
+ const scope = { tenantId: "tenant", assistantId: "assistant", workspaceId: null, projectId: null };
42
+ expect(await store.getThreadsWithPendingMessages()).toEqual([{
43
+ tenantId: "tenant",
44
+ assistantId: "assistant",
45
+ threadId: "legacy",
46
+ workspaceId: null,
47
+ projectId: null,
48
+ }]);
49
+ expect(await store.getPendingMessages("legacy", scope)).toHaveLength(1);
50
+ await store.markProcessing(message.id, undefined, scope);
51
+ expect(await store.getProcessingMessages("legacy", scope)).toHaveLength(1);
52
+ expect(await store.removeMessage(message.id, scope)).toBe(true);
53
+ expect(await store.getProcessingMessages("legacy", scope)).toHaveLength(0);
54
+ });
55
+
56
+ it("atomically accepts no more than the bounded number of messages", async () => {
57
+ const params = {
58
+ threadId: "bounded",
59
+ tenantId: "tenant",
60
+ assistantId: "assistant",
61
+ workspaceId: null,
62
+ projectId: null,
63
+ content: "message",
64
+ };
65
+
66
+ const accepted = await Promise.all(
67
+ Array.from({ length: 5 }, (_, index) => store.addMessageIfCapacity({ ...params, id: `msg-${index}` }, 2)),
68
+ );
69
+
70
+ expect(accepted.filter(Boolean)).toHaveLength(2);
71
+ expect(await store.getQueueSize("bounded", {
72
+ tenantId: "tenant",
73
+ assistantId: "assistant",
74
+ workspaceId: null,
75
+ projectId: null,
76
+ })).toBe(2);
77
+ });
78
+ });
@@ -37,6 +37,7 @@ import { LocalSkillStore } from "./stores/LocalSkillStore";
37
37
  import { LocalScheduleStorage } from "./stores/LocalScheduleStorage";
38
38
  import { LocalTaskStore } from "./stores/LocalTaskStore";
39
39
  import { LocalTaskWorkItemStore } from "./stores/LocalTaskWorkItemStore";
40
+ import { LocalCapabilityBundleStore } from "./stores/LocalCapabilityBundleStore";
40
41
 
41
42
  export interface LocalStoreConfigOptions {
42
43
  /**
@@ -82,6 +83,7 @@ export async function createLocalStoreConfig(options: LocalStoreConfigOptions =
82
83
  schedule: new LocalScheduleStorage(db),
83
84
  task: new LocalTaskStore(db),
84
85
  taskWorkItem: new LocalTaskWorkItemStore(db),
86
+ capabilityBundle: new LocalCapabilityBundleStore(db),
85
87
  checkpoint: SqliteSaver.fromConnString(dbPath),
86
88
  };
87
89
  }
package/src/index.ts CHANGED
@@ -48,4 +48,5 @@ export { LocalSkillStore } from "./stores/LocalSkillStore";
48
48
  export { LocalScheduleStorage } from "./stores/LocalScheduleStorage";
49
49
  export { LocalTaskStore } from "./stores/LocalTaskStore";
50
50
  export { LocalTaskWorkItemStore } from "./stores/LocalTaskWorkItemStore";
51
+ export { LocalCapabilityBundleStore } from "./stores/LocalCapabilityBundleStore";
51
52
  export { InMemoryConversationStore } from "./stores/InMemoryConversationStore";
@@ -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
+ }