@axiom-lattice/local-stores 2.0.4 → 2.0.6

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/jest.config.js ADDED
@@ -0,0 +1,19 @@
1
+ module.exports = {
2
+ preset: "ts-jest",
3
+ testEnvironment: "node",
4
+ roots: ["<rootDir>/src"],
5
+ testMatch: ["**/__tests__/**/*.test.ts"],
6
+ transform: {
7
+ "^.+\\.ts$": [
8
+ "ts-jest",
9
+ {
10
+ isolatedModules: true,
11
+ },
12
+ ],
13
+ },
14
+ moduleNameMapper: {
15
+ "^@axiom-lattice/protocols$": "<rootDir>/../protocols/src/index.ts",
16
+ },
17
+ transformIgnorePatterns: [],
18
+ testTimeout: 10000,
19
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/local-stores",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Local SQLite-based stores for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -25,13 +25,19 @@
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": "3.0.4",
28
+ "@axiom-lattice/core": "3.0.6",
29
29
  "@axiom-lattice/protocols": "3.0.2"
30
30
  },
31
31
  "devDependencies": {
32
+ "@types/jest": "^29.5.14",
32
33
  "@types/node": "^20.11.24",
33
34
  "@types/uuid": "^9.0.8",
35
+ "@typescript-eslint/eslint-plugin": "^7.2.0",
36
+ "@typescript-eslint/parser": "^7.2.0",
37
+ "eslint": "^8.57.0",
38
+ "jest": "^29.7.0",
34
39
  "rimraf": "^5.0.5",
40
+ "ts-jest": "^29.4.0",
35
41
  "tsup": "^8.0.1",
36
42
  "typescript": "^5.4.2"
37
43
  },
@@ -39,6 +45,7 @@
39
45
  "build": "tsup src/index.ts --format cjs,esm --dts --sourcemap",
40
46
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
41
47
  "lint": "eslint src",
48
+ "test": "jest",
42
49
  "clean": "rimraf dist"
43
50
  }
44
51
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * LocalEvalStore project name uniqueness tests
3
+ *
4
+ * Verifies the per-tenant unique name constraint enforced by the
5
+ * `idx_lt_eval_projects_tenant_name` index (eval-{agent-id} convention).
6
+ */
7
+
8
+ import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
9
+ import { tmpdir } from "os";
10
+ import path from "path";
11
+ import { initDatabase, closeDatabase, ensureTable } from "../database";
12
+ import { LocalEvalStore, DDL } from "../stores/LocalEvalStore";
13
+
14
+ // sql.js persists to disk via DatabaseWrapper.save(), so each test needs a
15
+ // unique file path (":memory:" is treated as a plain relative path).
16
+ let dbPath = "";
17
+ const nextDbPath = () => {
18
+ dbPath = path.join(tmpdir(), `lt-eval-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
19
+ return dbPath;
20
+ };
21
+
22
+ describe("LocalEvalStore project name uniqueness", () => {
23
+ let store: LocalEvalStore;
24
+
25
+ beforeEach(async () => {
26
+ const db = await initDatabase({ dbPath: nextDbPath() });
27
+ store = new LocalEvalStore(db);
28
+ });
29
+
30
+ afterEach(() => {
31
+ closeDatabase();
32
+ });
33
+
34
+ it("rejects a duplicate project name in the same tenant", async () => {
35
+ await store.createProject("tenant-1", "uuid-1", {
36
+ name: "eval-a1",
37
+ judgeModelConfig: {},
38
+ targetServerConfig: {},
39
+ });
40
+
41
+ await expect(
42
+ store.createProject("tenant-1", "uuid-2", {
43
+ name: "eval-a1",
44
+ judgeModelConfig: {},
45
+ targetServerConfig: {},
46
+ })
47
+ ).rejects.toThrow();
48
+ });
49
+
50
+ it("allows the same name in a different tenant", async () => {
51
+ await store.createProject("tenant-1", "uuid-1", {
52
+ name: "eval-a1",
53
+ judgeModelConfig: {},
54
+ targetServerConfig: {},
55
+ });
56
+
57
+ await expect(
58
+ store.createProject("tenant-2", "uuid-2", {
59
+ name: "eval-a1",
60
+ judgeModelConfig: {},
61
+ targetServerConfig: {},
62
+ })
63
+ ).resolves.toBeDefined();
64
+ });
65
+
66
+ it("deduplicates existing duplicate rows on construction and keeps child history", async () => {
67
+ closeDatabase();
68
+ const db = await initDatabase({ dbPath: nextDbPath() });
69
+ // Recreate the schema WITHOUT the unique index, simulating an old install
70
+ // that already holds duplicate rows.
71
+ ensureTable(db, DDL);
72
+ db.exec(`
73
+ INSERT INTO lt_eval_projects (id, tenant_id, name, judge_model_config, target_server_config, concurrency, created_at, updated_at)
74
+ VALUES
75
+ ('dup-old', 'tenant-1', 'eval-a1', '{}', '{}', 3, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'),
76
+ ('dup-new', 'tenant-1', 'eval-a1', '{}', '{}', 3, '2026-01-02T00:00:00.000Z', '2026-01-02T00:00:00.000Z');
77
+ INSERT INTO lt_eval_suites (id, tenant_id, project_id, name, created_at, updated_at)
78
+ VALUES ('suite-1', 'tenant-1', 'dup-old', 'refund-path', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
79
+ INSERT INTO lt_eval_runs (id, project_id, tenant_id, status, concurrency, total_cases, created_at)
80
+ VALUES ('run-1', 'dup-old', 'tenant-1', 'completed', 1, 1, '2026-01-01T00:00:00.000Z');
81
+ `);
82
+
83
+ const cleaned = new LocalEvalStore(db);
84
+ const projects = await cleaned.getProjectsByTenant("tenant-1");
85
+
86
+ // Kept the newest duplicate (dup-new, created 2026-01-02).
87
+ expect(projects).toHaveLength(1);
88
+ expect(projects[0].id).toBe("dup-new");
89
+
90
+ // Child history was repointed to the kept project, not deleted.
91
+ const suites = db.prepare(`SELECT project_id FROM lt_eval_suites WHERE id = 'suite-1'`).get() as { project_id: string };
92
+ expect(suites.project_id).toBe("dup-new");
93
+ const runs = db.prepare(`SELECT project_id FROM lt_eval_runs WHERE id = 'run-1'`).get() as { project_id: string };
94
+ expect(runs.project_id).toBe("dup-new");
95
+ });
96
+ });
@@ -15,11 +15,12 @@ import {
15
15
  CreateEvalRunRequest,
16
16
  EvalRunResult,
17
17
  EvalProjectReport,
18
+ InterruptPolicy,
18
19
  } from "@axiom-lattice/protocols";
19
20
  import { ensureTable, nowISO, parseISO } from "../database";
20
21
  import { randomUUID } from "crypto";
21
22
 
22
- const DDL = `
23
+ export const DDL = `
23
24
  CREATE TABLE IF NOT EXISTS lt_eval_projects (
24
25
  id TEXT NOT NULL,
25
26
  tenant_id TEXT NOT NULL,
@@ -56,6 +57,7 @@ CREATE TABLE IF NOT EXISTS lt_eval_cases (
56
57
  output_type TEXT NOT NULL DEFAULT 'message_content',
57
58
  content_assertion TEXT NOT NULL DEFAULT '',
58
59
  rubrics TEXT,
60
+ interrupt_policy TEXT,
59
61
  created_at TEXT NOT NULL,
60
62
  updated_at TEXT NOT NULL,
61
63
  PRIMARY KEY (tenant_id, id)
@@ -71,6 +73,7 @@ CREATE TABLE IF NOT EXISTS lt_eval_runs (
71
73
  total_cases INTEGER NOT NULL DEFAULT 0,
72
74
  passed_cases INTEGER NOT NULL DEFAULT 0,
73
75
  failed_cases INTEGER NOT NULL DEFAULT 0,
76
+ interrupted_cases INTEGER NOT NULL DEFAULT 0,
74
77
  avg_score REAL NOT NULL DEFAULT 0,
75
78
  error TEXT,
76
79
  holdout INTEGER NOT NULL DEFAULT 0,
@@ -96,6 +99,7 @@ CREATE TABLE IF NOT EXISTS lt_eval_run_results (
96
99
  messages TEXT DEFAULT '[]',
97
100
  logs TEXT DEFAULT '[]',
98
101
  error TEXT,
102
+ interrupted INTEGER NOT NULL DEFAULT 0,
99
103
  created_at TEXT NOT NULL,
100
104
  PRIMARY KEY (run_id, id)
101
105
  );
@@ -123,6 +127,85 @@ export class LocalEvalStore implements EvalStore {
123
127
  constructor(db: DatabaseWrapper) {
124
128
  this.db = db;
125
129
  ensureTable(db, DDL);
130
+ // Existing installs: add HITL interrupt tracking columns.
131
+ // sql.js bundles an older SQLite that does not support
132
+ // `ADD COLUMN IF NOT EXISTS`, so check via PRAGMA instead.
133
+ this.ensureColumn("lt_eval_runs", "interrupted_cases", "interrupted_cases INTEGER NOT NULL DEFAULT 0");
134
+ this.ensureColumn("lt_eval_run_results", "interrupted", "interrupted INTEGER NOT NULL DEFAULT 0");
135
+ this.ensureColumn("lt_eval_cases", "interrupt_policy", "interrupt_policy TEXT");
136
+ // Project name must be unique per tenant (eval-{agent-id} convention).
137
+ // Repoint child rows (suites, runs) of duplicate projects onto the kept
138
+ // project (newest by created_at, tie-break rowid — mirrors the pg
139
+ // migration's (created_at, id) DESC policy) BEFORE deleting, so eval
140
+ // history is preserved. SQLite has no FK cascade here, so this is manual.
141
+ this.db.exec(`
142
+ UPDATE lt_eval_suites
143
+ SET project_id = (
144
+ SELECT p2.id FROM lt_eval_projects p2
145
+ WHERE p2.tenant_id = (
146
+ SELECT p.tenant_id FROM lt_eval_projects p WHERE p.id = lt_eval_suites.project_id
147
+ )
148
+ AND p2.name = (
149
+ SELECT p.name FROM lt_eval_projects p WHERE p.id = lt_eval_suites.project_id
150
+ )
151
+ ORDER BY p2.created_at DESC, p2.rowid DESC
152
+ LIMIT 1
153
+ )
154
+ WHERE project_id IN (
155
+ SELECT p3.id FROM lt_eval_projects p3
156
+ WHERE p3.id <> (
157
+ SELECT p2.id FROM lt_eval_projects p2
158
+ WHERE p2.tenant_id = p3.tenant_id AND p2.name = p3.name
159
+ ORDER BY p2.created_at DESC, p2.rowid DESC
160
+ LIMIT 1
161
+ )
162
+ )
163
+ `);
164
+ this.db.exec(`
165
+ UPDATE lt_eval_runs
166
+ SET project_id = (
167
+ SELECT p2.id FROM lt_eval_projects p2
168
+ WHERE p2.tenant_id = (
169
+ SELECT p.tenant_id FROM lt_eval_projects p WHERE p.id = lt_eval_runs.project_id
170
+ )
171
+ AND p2.name = (
172
+ SELECT p.name FROM lt_eval_projects p WHERE p.id = lt_eval_runs.project_id
173
+ )
174
+ ORDER BY p2.created_at DESC, p2.rowid DESC
175
+ LIMIT 1
176
+ )
177
+ WHERE project_id IN (
178
+ SELECT p3.id FROM lt_eval_projects p3
179
+ WHERE p3.id <> (
180
+ SELECT p2.id FROM lt_eval_projects p2
181
+ WHERE p2.tenant_id = p3.tenant_id AND p2.name = p3.name
182
+ ORDER BY p2.created_at DESC, p2.rowid DESC
183
+ LIMIT 1
184
+ )
185
+ )
186
+ `);
187
+ this.db.exec(`
188
+ DELETE FROM lt_eval_projects
189
+ WHERE id <> (
190
+ SELECT p2.id FROM lt_eval_projects p2
191
+ WHERE p2.tenant_id = lt_eval_projects.tenant_id
192
+ AND p2.name = lt_eval_projects.name
193
+ ORDER BY p2.created_at DESC, p2.rowid DESC
194
+ LIMIT 1
195
+ )
196
+ `);
197
+ this.db.exec(`
198
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_lt_eval_projects_tenant_name
199
+ ON lt_eval_projects(tenant_id, name)
200
+ `);
201
+ }
202
+
203
+ /** Add a column if it does not exist (SQLite version compatible). */
204
+ private ensureColumn(table: string, column: string, ddl: string): void {
205
+ const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
206
+ if (!cols.some((c) => c.name === column)) {
207
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
208
+ }
126
209
  }
127
210
 
128
211
  // -------------------------------------------------------------------------
@@ -252,13 +335,14 @@ export class LocalEvalStore implements EvalStore {
252
335
  async createCase(tenantId: string, suiteId: string, id: string, data: CreateEvalCaseRequest): Promise<EvalCase> {
253
336
  const actualId = id || randomUUID();
254
337
  this.db.prepare(
255
- `INSERT INTO lt_eval_cases (id, tenant_id, suite_id, input_message, input_files, steps, output_type, content_assertion, rubrics, created_at, updated_at)
256
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
338
+ `INSERT INTO lt_eval_cases (id, tenant_id, suite_id, input_message, input_files, steps, output_type, content_assertion, rubrics, interrupt_policy, created_at, updated_at)
339
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
257
340
  ).run(actualId, tenantId, suiteId, data.inputMessage,
258
341
  data.inputFiles ? JSON.stringify(data.inputFiles) : null,
259
342
  JSON.stringify(data.steps), data.outputType || "message_content",
260
343
  data.contentAssertion || "",
261
344
  data.rubrics ? JSON.stringify(data.rubrics) : null,
345
+ data.interruptPolicy ? JSON.stringify(data.interruptPolicy) : null,
262
346
  nowISO(), nowISO());
263
347
  return (await this.getCaseById(tenantId, actualId))!;
264
348
  }
@@ -275,6 +359,7 @@ export class LocalEvalStore implements EvalStore {
275
359
  if (updates.outputType !== undefined) { set.push("output_type = ?"); vals.push(updates.outputType); }
276
360
  if (updates.contentAssertion !== undefined) { set.push("content_assertion = ?"); vals.push(updates.contentAssertion); }
277
361
  if (updates.rubrics !== undefined) { set.push("rubrics = ?"); vals.push(JSON.stringify(updates.rubrics)); }
362
+ if (updates.interruptPolicy !== undefined) { set.push("interrupt_policy = ?"); vals.push(updates.interruptPolicy ? JSON.stringify(updates.interruptPolicy) : null); }
278
363
  set.push("updated_at = ?"); vals.push(nowISO());
279
364
  vals.push(tenantId, id);
280
365
 
@@ -320,7 +405,7 @@ export class LocalEvalStore implements EvalStore {
320
405
  }
321
406
 
322
407
  async updateRunStatus(tenantId: string, id: string, updates: {
323
- status?: EvalRun["status"]; passedCases?: number; failedCases?: number; avgScore?: number; error?: string; completedAt?: Date;
408
+ status?: EvalRun["status"]; passedCases?: number; failedCases?: number; interruptedCases?: number; avgScore?: number; error?: string; completedAt?: Date;
324
409
  }): Promise<EvalRun | null> {
325
410
  const existing = await this.getRunById(tenantId, id);
326
411
  if (!existing) return null;
@@ -330,6 +415,7 @@ export class LocalEvalStore implements EvalStore {
330
415
  if (updates.status !== undefined) { set.push("status = ?"); vals.push(updates.status); }
331
416
  if (updates.passedCases !== undefined) { set.push("passed_cases = ?"); vals.push(updates.passedCases); }
332
417
  if (updates.failedCases !== undefined) { set.push("failed_cases = ?"); vals.push(updates.failedCases); }
418
+ if (updates.interruptedCases !== undefined) { set.push("interrupted_cases = ?"); vals.push(updates.interruptedCases); }
333
419
  if (updates.avgScore !== undefined) { set.push("avg_score = ?"); vals.push(updates.avgScore); }
334
420
  if (updates.error !== undefined) { set.push("error = ?"); vals.push(updates.error || null); }
335
421
  if (updates.completedAt !== undefined) { set.push("completed_at = ?"); vals.push(updates.completedAt ? updates.completedAt.toISOString() : null); }
@@ -371,13 +457,13 @@ export class LocalEvalStore implements EvalStore {
371
457
  async createRunResult(tenantId: string, runId: string, id: string, data: Omit<EvalRunResult, "id" | "runId" | "createdAt">): Promise<EvalRunResult> {
372
458
  const actualId = id || randomUUID();
373
459
  this.db.prepare(
374
- `INSERT INTO lt_eval_run_results (id, run_id, suite_name, case_id, pass, score, summary, dimension_results, duration_ms, messages, logs, error, created_at)
375
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
460
+ `INSERT INTO lt_eval_run_results (id, run_id, suite_name, case_id, pass, score, summary, dimension_results, duration_ms, messages, logs, error, interrupted, created_at)
461
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
376
462
  ).run(actualId, runId, data.suiteName, data.caseId || null,
377
463
  data.pass ? 1 : 0, data.score, data.summary || null,
378
464
  JSON.stringify(data.dimensionResults || []), data.durationMs ?? null,
379
465
  JSON.stringify(data.messages || []), JSON.stringify(data.logs || []),
380
- data.error || null, nowISO());
466
+ data.error || null, data.interrupted ? 1 : 0, nowISO());
381
467
  return (await this.getRunResultById(tenantId, actualId))!;
382
468
  }
383
469
 
@@ -396,6 +482,7 @@ export class LocalEvalStore implements EvalStore {
396
482
  if (updates.messages !== undefined) { set.push("messages = ?"); vals.push(JSON.stringify(updates.messages)); }
397
483
  if (updates.logs !== undefined) { set.push("logs = ?"); vals.push(JSON.stringify(updates.logs)); }
398
484
  if (updates.error !== undefined) { set.push("error = ?"); vals.push(updates.error || null); }
485
+ if (updates.interrupted !== undefined) { set.push("interrupted = ?"); vals.push(updates.interrupted ? 1 : 0); }
399
486
  if (set.length === 0) return this.getRunResultById(tenantId, id);
400
487
 
401
488
  vals.push(id, tenantId);
@@ -478,6 +565,7 @@ export class LocalEvalStore implements EvalStore {
478
565
  outputType: (row.output_type as EvalCase["outputType"]) || "message_content",
479
566
  contentAssertion: (row.content_assertion as string) || "",
480
567
  rubrics: parseOptionalJson<Array<{ name: string; weight: number; description: string }>>(row.rubrics),
568
+ interruptPolicy: parseOptionalJson<InterruptPolicy>(row.interrupt_policy),
481
569
  createdAt: parseISO(row.created_at as string),
482
570
  updatedAt: parseISO(row.updated_at as string),
483
571
  };
@@ -493,6 +581,7 @@ export class LocalEvalStore implements EvalStore {
493
581
  totalCases: (row.total_cases as number) ?? 0,
494
582
  passedCases: (row.passed_cases as number) ?? 0,
495
583
  failedCases: (row.failed_cases as number) ?? 0,
584
+ interruptedCases: (row.interrupted_cases as number) ?? 0,
496
585
  avgScore: (row.avg_score as number) ?? 0,
497
586
  error: row.error as string | undefined,
498
587
  holdout: row.holdout ? Boolean(row.holdout) : undefined,
@@ -518,6 +607,7 @@ export class LocalEvalStore implements EvalStore {
518
607
  messages: parseOptionalJson<Array<{ role: string; content: string; id?: string }>>(row.messages),
519
608
  logs: parseOptionalJson<Array<{ timestamp: string; level: string; message: string; data?: unknown }>>(row.logs),
520
609
  error: row.error as string | undefined,
610
+ interrupted: (row.interrupted as number) === 1,
521
611
  createdAt: parseISO(row.created_at as string),
522
612
  };
523
613
  }