@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.
@@ -264,8 +264,34 @@ export class LocalEvalStore implements EvalStore {
264
264
  }
265
265
 
266
266
  async deleteProject(tenantId: string, id: string): Promise<boolean> {
267
- const result = this.db.prepare(`DELETE FROM lt_eval_projects WHERE tenant_id = ? AND id = ?`).run(tenantId, id);
268
- return result.changes > 0;
267
+ const db = this.db.getRawDb();
268
+ db.run("BEGIN TRANSACTION");
269
+ try {
270
+ db.run(
271
+ `DELETE FROM lt_eval_run_results
272
+ WHERE run_id IN (
273
+ SELECT id FROM lt_eval_runs WHERE tenant_id = ? AND project_id = ?
274
+ )`,
275
+ [tenantId, id]
276
+ );
277
+ db.run(
278
+ `DELETE FROM lt_eval_cases
279
+ WHERE tenant_id = ? AND suite_id IN (
280
+ SELECT id FROM lt_eval_suites WHERE tenant_id = ? AND project_id = ?
281
+ )`,
282
+ [tenantId, tenantId, id]
283
+ );
284
+ db.run(`DELETE FROM lt_eval_runs WHERE tenant_id = ? AND project_id = ?`, [tenantId, id]);
285
+ db.run(`DELETE FROM lt_eval_suites WHERE tenant_id = ? AND project_id = ?`, [tenantId, id]);
286
+ db.run(`DELETE FROM lt_eval_projects WHERE tenant_id = ? AND id = ?`, [tenantId, id]);
287
+ const deleted = db.getRowsModified() > 0;
288
+ db.run("COMMIT");
289
+ this.db.save();
290
+ return deleted;
291
+ } catch (error) {
292
+ db.run("ROLLBACK");
293
+ throw error;
294
+ }
269
295
  }
270
296
 
271
297
  // -------------------------------------------------------------------------
@@ -9,7 +9,9 @@ import type {
9
9
  ProjectFilter,
10
10
  CreateProjectRequest,
11
11
  UpdateProjectRequest,
12
+ UpdateProjectCapabilityBundlesResult,
12
13
  } from "@axiom-lattice/protocols";
14
+ import { assertGenericProjectConfig } from "@axiom-lattice/protocols";
13
15
  import { ensureTable, nowISO, parseISO } from "../database";
14
16
 
15
17
  const DDL = `
@@ -75,6 +77,7 @@ export class LocalProjectStore implements ProjectStore {
75
77
  id: string,
76
78
  data: CreateProjectRequest,
77
79
  ): Promise<Project> {
80
+ assertGenericProjectConfig(data.config);
78
81
  const now = nowISO();
79
82
  const kind = data.kind || "business";
80
83
 
@@ -85,22 +88,16 @@ export class LocalProjectStore implements ProjectStore {
85
88
  workspace_id = excluded.workspace_id,
86
89
  name = excluded.name,
87
90
  description = excluded.description,
88
- config = excluded.config,
91
+ config = CASE
92
+ WHEN json_type(lt_projects.config, '$.capabilityBundleIds') IS NOT NULL
93
+ THEN json_set(COALESCE(excluded.config, '{}'), '$.capabilityBundleIds', json_extract(lt_projects.config, '$.capabilityBundleIds'))
94
+ ELSE excluded.config
95
+ END,
89
96
  kind = excluded.kind,
90
97
  updated_at = excluded.updated_at`,
91
98
  ).run(id, tenantId, workspaceId, data.name, data.description || null, data.config ? JSON.stringify(data.config) : null, kind, now, now);
92
99
 
93
- return {
94
- id,
95
- tenantId,
96
- workspaceId,
97
- name: data.name,
98
- description: data.description,
99
- config: data.config,
100
- kind,
101
- createdAt: parseISO(now),
102
- updatedAt: parseISO(now),
103
- };
100
+ return (await this.getProjectById(tenantId, id)) as Project;
104
101
  }
105
102
 
106
103
  async updateProject(
@@ -108,6 +105,7 @@ export class LocalProjectStore implements ProjectStore {
108
105
  id: string,
109
106
  updates: UpdateProjectRequest,
110
107
  ): Promise<Project | null> {
108
+ assertGenericProjectConfig(updates.config);
111
109
  const existing = await this.getProjectById(tenantId, id);
112
110
  if (!existing) return null;
113
111
 
@@ -123,8 +121,13 @@ export class LocalProjectStore implements ProjectStore {
123
121
  values.push(updates.description || null);
124
122
  }
125
123
  if (updates.config !== undefined) {
126
- setClauses.push("config = ?");
127
- values.push(updates.config ? JSON.stringify(updates.config) : null);
124
+ setClauses.push(`config = CASE
125
+ WHEN json_type(config, '$.capabilityBundleIds') IS NOT NULL
126
+ THEN json_set(json(?), '$.capabilityBundleIds', json_extract(config, '$.capabilityBundleIds'))
127
+ ELSE ?
128
+ END`);
129
+ const config = JSON.stringify(updates.config ?? {});
130
+ values.push(config, config);
128
131
  }
129
132
  if (updates.kind !== undefined) {
130
133
  setClauses.push("kind = ?");
@@ -152,6 +155,56 @@ export class LocalProjectStore implements ProjectStore {
152
155
  return result.changes > 0;
153
156
  }
154
157
 
158
+ async updateCapabilityBundleIds(tenantId: string, projectId: string, bundleIds: string[], expectedRevisions: Record<string, string> = {}): Promise<UpdateProjectCapabilityBundlesResult> {
159
+ const now = nowISO();
160
+ const result = this.db.prepare(
161
+ `UPDATE lt_projects
162
+ SET config = json_set(COALESCE(config, '{}'), '$.capabilityBundleIds', json(?)), updated_at = ?
163
+ WHERE tenant_id = ? AND id = ?
164
+ AND NOT EXISTS (
165
+ SELECT 1 FROM json_each(?) AS requested
166
+ WHERE NOT EXISTS (
167
+ SELECT 1 FROM lt_capability_bundles
168
+ WHERE tenant_id = ? AND id = requested.value
169
+ )
170
+ )
171
+ AND NOT EXISTS (
172
+ SELECT 1 FROM json_each(?) AS expected
173
+ WHERE EXISTS (
174
+ SELECT 1 FROM lt_capability_bundles
175
+ WHERE tenant_id = ? AND id = expected.key AND updated_at <> expected.value
176
+ )
177
+ )`,
178
+ ).run(JSON.stringify(bundleIds), now, tenantId, projectId, JSON.stringify(bundleIds), tenantId, JSON.stringify(expectedRevisions), tenantId);
179
+ if (result.changes > 0) {
180
+ const project = await this.getProjectById(tenantId, projectId);
181
+ return project ? { status: "updated", project } : { status: "project_not_found" };
182
+ }
183
+ const revisionMismatch = this.db.prepare(
184
+ "SELECT 1 FROM lt_capability_bundles WHERE tenant_id = ? AND id IN (SELECT key FROM json_each(?)) AND updated_at <> (SELECT value FROM json_each(?) WHERE key = lt_capability_bundles.id) LIMIT 1",
185
+ ).get(tenantId, JSON.stringify(expectedRevisions), JSON.stringify(expectedRevisions));
186
+ if (revisionMismatch) return { status: "bundle_conflict" };
187
+ return await this.getProjectById(tenantId, projectId)
188
+ ? { status: "bundle_not_found" }
189
+ : { status: "project_not_found" };
190
+ }
191
+
192
+ async isCapabilityBundleReferenced(tenantId: string, bundleId: string): Promise<boolean> {
193
+ const rows = this.db.prepare("SELECT config FROM lt_projects WHERE tenant_id = ?").all(tenantId) as unknown as Array<{ config: string | null }>;
194
+ return rows.some((row) => {
195
+ if (!row.config) return false;
196
+ let config: Record<string, unknown>;
197
+ try {
198
+ config = JSON.parse(row.config) as Record<string, unknown>;
199
+ } catch {
200
+ return false;
201
+ }
202
+ if (config === null || typeof config !== "object" || Array.isArray(config)) return false;
203
+ const ids = config.capabilityBundleIds;
204
+ return Array.isArray(ids) && ids.every((id) => typeof id === "string") && ids.includes(bundleId);
205
+ });
206
+ }
207
+
155
208
  /** Add a column if it does not exist (SQLite version compatible). */
156
209
  private ensureColumn(table: string, column: string, ddl: string): void {
157
210
  const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{ name: string }>;
@@ -10,6 +10,7 @@ import type {
10
10
  CreateWorkItemIfAbsentRequest,
11
11
  TaskWorkItemListFilter,
12
12
  } from "@axiom-lattice/protocols";
13
+ import { MAX_PENDING_EXECUTION_RESULTS_LIMIT } from "@axiom-lattice/protocols";
13
14
  import { ensureTable, nowISO, parseISO } from "../database";
14
15
  import { v4 as uuid } from "uuid";
15
16
 
@@ -76,6 +77,11 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
76
77
  this.ensureColumn("lt_task_work_items", "event_key", "event_key TEXT");
77
78
  this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_task_work_items_event_key
78
79
  ON lt_task_work_items (tenant_id, task_id, event_key);`);
80
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_task_work_items_pending_order
81
+ ON lt_task_work_items (tenant_id, task_id, action, created_at DESC, id DESC);`);
82
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_task_work_items_reconciled_result
83
+ ON lt_task_work_items (tenant_id, task_id, json_extract(detail, '$.executionResultId'))
84
+ WHERE action = 'execution_reconciled';`);
79
85
  }
80
86
 
81
87
  /** Add a column if it does not exist (SQLite version compatible). */
@@ -154,4 +160,43 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
154
160
  ).all(...params, limit, offset) as unknown as WorkItemRow[];
155
161
  return rows.map(mapRowToWorkItem);
156
162
  }
163
+
164
+ /** List pending execution results using one bounded SQLite anti-join query. */
165
+ async listPendingExecutionResults(params: {
166
+ tenantId: string;
167
+ taskId: string;
168
+ limit: number;
169
+ }): Promise<TaskWorkItem[]> {
170
+ if (!Number.isSafeInteger(params.limit)
171
+ || params.limit < 0
172
+ || params.limit > MAX_PENDING_EXECUTION_RESULTS_LIMIT) {
173
+ const error = new RangeError(`limit must be a safe integer between 0 and ${MAX_PENDING_EXECUTION_RESULTS_LIMIT}`) as RangeError & {
174
+ code: "INVALID_LIMIT";
175
+ };
176
+ error.code = "INVALID_LIMIT";
177
+ throw error;
178
+ }
179
+ if (params.limit === 0) return [];
180
+ const rows = this.db.prepare(
181
+ `SELECT result.*
182
+ FROM lt_task_work_items AS result
183
+ WHERE result.tenant_id = ?
184
+ AND result.task_id = ?
185
+ AND result.action = 'execution_result'
186
+ AND substr(result.event_key, 1, 17) = 'execution-result:'
187
+ AND length(result.event_key) > 17
188
+ AND result.event_key NOT GLOB '*[^A-Za-z0-9._:-]*'
189
+ AND NOT EXISTS (
190
+ SELECT 1
191
+ FROM lt_task_work_items AS reconciled
192
+ WHERE reconciled.tenant_id = result.tenant_id
193
+ AND reconciled.task_id = result.task_id
194
+ AND reconciled.action = 'execution_reconciled'
195
+ AND json_extract(reconciled.detail, '$.executionResultId') = result.event_key
196
+ )
197
+ ORDER BY result.created_at DESC, result.id DESC
198
+ LIMIT ?`,
199
+ ).all(params.tenantId, params.taskId, params.limit) as unknown as WorkItemRow[];
200
+ return rows.map(mapRowToWorkItem);
201
+ }
157
202
  }
@@ -8,6 +8,7 @@ import type {
8
8
  PendingMessage,
9
9
  AddMessageParams,
10
10
  ThreadInfo,
11
+ QueueScope,
11
12
  } from "@axiom-lattice/core";
12
13
  import { ensureTable, nowISO, parseISO } from "../database";
13
14
  import { randomUUID } from "crypto";
@@ -52,6 +53,7 @@ interface QueueRow {
52
53
 
53
54
  export class LocalThreadMessageQueueStore implements IMessageQueueStore {
54
55
  private db: DatabaseWrapper;
56
+ private capacityTail: Promise<void> = Promise.resolve();
55
57
 
56
58
  constructor(db: DatabaseWrapper) {
57
59
  this.db = db;
@@ -72,9 +74,9 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
72
74
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
73
75
  ).run(
74
76
  id, params.threadId, params.tenantId, params.assistantId,
75
- params.workspaceId || null, params.projectId || null,
77
+ params.workspaceId ?? null, params.projectId ?? null,
76
78
  JSON.stringify(params.content), params.type || "human", nextSeq,
77
- params.priority || 0,
79
+ params.priority ?? 0,
78
80
  params.command ? JSON.stringify(params.command) : null,
79
81
  params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
80
82
  now,
@@ -83,6 +85,43 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
83
85
  return this.getById(id);
84
86
  }
85
87
 
88
+ async addMessageIfCapacity(params: AddMessageParams, maxSize: number): Promise<boolean> {
89
+ let release!: () => void;
90
+ const previous = this.capacityTail;
91
+ this.capacityTail = new Promise<void>((resolve) => { release = resolve; });
92
+ await previous;
93
+ try {
94
+ const scope = {
95
+ tenantId: params.tenantId,
96
+ assistantId: params.assistantId,
97
+ workspaceId: params.workspaceId,
98
+ projectId: params.projectId,
99
+ };
100
+ const filter = scopeClause(scope);
101
+ const id = params.id || randomUUID();
102
+ const limit = maxSize === Infinity ? -1 : maxSize;
103
+ const result = this.db.prepare(
104
+ `INSERT INTO lt_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, created_at)
105
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?,
106
+ (SELECT COALESCE(MAX(sequence_order), 0) + 1 FROM lt_thread_message_queue WHERE thread_id = ?),
107
+ ?, ?, ?, ?
108
+ WHERE ? = -1 OR (SELECT COUNT(*) FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql}) < ?`,
109
+ ).run(
110
+ id, params.threadId, params.tenantId, params.assistantId,
111
+ params.workspaceId ?? null, params.projectId ?? null,
112
+ JSON.stringify(params.content), params.type || "human",
113
+ params.threadId,
114
+ params.priority ?? 0,
115
+ params.command ? JSON.stringify(params.command) : null,
116
+ params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
117
+ nowISO(), limit, params.threadId, ...filter.params, limit,
118
+ );
119
+ return result.changes > 0;
120
+ } finally {
121
+ release();
122
+ }
123
+ }
124
+
86
125
  async addMessageAtHead(params: AddMessageParams): Promise<PendingMessage> {
87
126
  const now = nowISO();
88
127
  const id = params.id || randomUUID();
@@ -96,7 +135,7 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
96
135
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 100, ?, ?, ?)`,
97
136
  ).run(
98
137
  id, params.threadId, params.tenantId!, params.assistantId!,
99
- params.workspaceId || null, params.projectId || null,
138
+ params.workspaceId ?? null, params.projectId ?? null,
100
139
  JSON.stringify(params.content), params.type || "human", seqRow.next_seq,
101
140
  params.command ? JSON.stringify(params.command) : null,
102
141
  params.custom_run_config ? JSON.stringify(params.custom_run_config) : null,
@@ -106,57 +145,70 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
106
145
  return this.getById(id);
107
146
  }
108
147
 
109
- async getPendingMessages(threadId: string): Promise<PendingMessage[]> {
148
+ async getPendingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
149
+ const filter = scopeClause(scope);
110
150
  const rows = this.db.prepare(
111
- `SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending' ORDER BY priority DESC, sequence_order ASC`,
112
- ).all(threadId) as unknown as QueueRow[];
151
+ `SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql} ORDER BY priority DESC, sequence_order ASC`,
152
+ ).all(threadId, ...filter.params) as unknown as QueueRow[];
113
153
  return rows.map(rowToMessage);
114
154
  }
115
155
 
116
- async getProcessingMessages(threadId: string): Promise<PendingMessage[]> {
156
+ async getProcessingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
157
+ const filter = scopeClause(scope);
117
158
  const rows = this.db.prepare(
118
- `SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'processing' ORDER BY priority DESC, sequence_order ASC`,
119
- ).all(threadId) as unknown as QueueRow[];
159
+ `SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'processing'${filter.sql} ORDER BY priority DESC, sequence_order ASC`,
160
+ ).all(threadId, ...filter.params) as unknown as QueueRow[];
120
161
  return rows.map(rowToMessage);
121
162
  }
122
163
 
123
- async getQueueSize(threadId: string): Promise<number> {
164
+ async getQueueSize(threadId: string, scope?: QueueScope): Promise<number> {
165
+ const filter = scopeClause(scope);
124
166
  const row = this.db.prepare(
125
- `SELECT COUNT(*) as count FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'`,
126
- ).get(threadId) as { count: number };
167
+ `SELECT COUNT(*) as count FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql}`,
168
+ ).get(threadId, ...filter.params) as { count: number };
127
169
  return row.count;
128
170
  }
129
171
 
130
172
  async getThreadsWithPendingMessages(): Promise<ThreadInfo[]> {
131
173
  const rows = this.db.prepare(
132
- `SELECT DISTINCT ON (thread_id) tenant_id, assistant_id, thread_id, workspace_id, project_id FROM lt_thread_message_queue WHERE status IN ('pending', 'processing') ORDER BY thread_id`,
174
+ `SELECT tenant_id, assistant_id, thread_id, workspace_id, project_id FROM lt_thread_message_queue WHERE status IN ('pending', 'processing') GROUP BY tenant_id, assistant_id, thread_id, workspace_id, project_id ORDER BY thread_id`,
133
175
  ).all() as { tenant_id: string; assistant_id: string; thread_id: string; workspace_id: string | null; project_id: string | null }[];
134
176
  return rows.map((r) => ({
135
177
  tenantId: r.tenant_id,
136
178
  assistantId: r.assistant_id,
137
179
  threadId: r.thread_id,
138
- workspaceId: r.workspace_id || undefined,
139
- projectId: r.project_id || undefined,
180
+ workspaceId: r.workspace_id,
181
+ projectId: r.project_id,
140
182
  }));
141
183
  }
142
184
 
143
- async removeMessage(messageId: string): Promise<boolean> {
144
- const result = this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE id = ?`).run(messageId);
185
+ async removeMessage(messageId: string, scope?: QueueScope): Promise<boolean> {
186
+ const filter = scopeClause(scope);
187
+ const result = this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE id = ?${filter.sql}`).run(messageId, ...filter.params);
145
188
  return result.changes > 0;
146
189
  }
147
190
 
148
- async clearMessages(threadId: string): Promise<void> {
149
- this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE thread_id = ?`).run(threadId);
191
+ async clearMessages(threadId: string, scope?: QueueScope): Promise<void> {
192
+ const filter = scopeClause(scope);
193
+ this.db.prepare(`DELETE FROM lt_thread_message_queue WHERE thread_id = ?${filter.sql}`).run(threadId, ...filter.params);
150
194
  }
151
195
 
152
- async markProcessing(messageId: string): Promise<void> {
153
- this.db.prepare(`UPDATE lt_thread_message_queue SET status = 'processing' WHERE id = ?`).run(messageId);
196
+ async markProcessing(messageId: string, customRunConfig?: unknown, scope?: QueueScope): Promise<void> {
197
+ const filter = scopeClause(scope);
198
+ if (customRunConfig === undefined) {
199
+ this.db.prepare(`UPDATE lt_thread_message_queue SET status = 'processing' WHERE id = ?${filter.sql}`).run(messageId, ...filter.params);
200
+ return;
201
+ }
202
+ this.db.prepare(
203
+ `UPDATE lt_thread_message_queue SET status = 'processing', custom_run_config = ? WHERE id = ?${filter.sql}`,
204
+ ).run(JSON.stringify(customRunConfig), messageId, ...filter.params);
154
205
  }
155
206
 
156
- async resetProcessingToPending(threadId: string): Promise<number> {
207
+ async resetProcessingToPending(threadId: string, scope?: QueueScope): Promise<number> {
208
+ const filter = scopeClause(scope);
157
209
  const result = this.db.prepare(
158
- `UPDATE lt_thread_message_queue SET status = 'pending' WHERE thread_id = ? AND status = 'processing'`,
159
- ).run(threadId);
210
+ `UPDATE lt_thread_message_queue SET status = 'pending' WHERE thread_id = ? AND status = 'processing'${filter.sql}`,
211
+ ).run(threadId, ...filter.params);
160
212
  return result.changes;
161
213
  }
162
214
 
@@ -166,6 +218,16 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
166
218
  }
167
219
  }
168
220
 
221
+ function scopeClause(scope?: QueueScope): { sql: string; params: unknown[] } {
222
+ if (!scope) return { sql: "", params: [] };
223
+ const entries = (["tenantId", "assistantId", "workspaceId", "projectId"] as const)
224
+ .map((key) => [key, scope[key]] as const);
225
+ return {
226
+ sql: entries.map(([key, value]) => ` AND ${key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)} ${value == null ? "IS NULL" : "= ?"}`).join(""),
227
+ params: entries.flatMap(([, value]) => value == null ? [] : [value]),
228
+ };
229
+ }
230
+
169
231
  function rowToMessage(row: QueueRow): PendingMessage {
170
232
  return {
171
233
  id: row.id,