@axiom-lattice/local-stores 2.0.9 → 3.0.0

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.
@@ -0,0 +1,215 @@
1
+ import { tmpdir } from "os";
2
+ import path from "path";
3
+ import { unlinkSync } from "fs";
4
+ import { closeDatabase, initDatabase, type DatabaseWrapper } from "../database";
5
+ import { LocalTaskWorkItemStore } from "../stores/LocalTaskWorkItemStore";
6
+
7
+ jest.mock("uuid", () => {
8
+ let id = 0;
9
+ return { v4: jest.fn(() => `test-id-${++id}`) };
10
+ });
11
+
12
+ const rows = [
13
+ {
14
+ id: "item-1",
15
+ tenant_id: "tenant-1",
16
+ task_id: "task-1",
17
+ action: "activity",
18
+ actor: "agent:a",
19
+ thread_id: null,
20
+ summary: null,
21
+ detail: null,
22
+ attempt: null,
23
+ workspace_id: null,
24
+ project_id: null,
25
+ created_at: "2026-01-02T00:00:00.000Z",
26
+ },
27
+ ];
28
+
29
+ async function openTaskWorkItemStore(dbPath: string): Promise<LocalTaskWorkItemStore> {
30
+ const db = await initDatabase({ dbPath });
31
+ const exec = db.exec.bind(db);
32
+ jest.spyOn(db, "exec").mockImplementation((sql) => {
33
+ try {
34
+ exec(sql);
35
+ } catch (error) {
36
+ if (!(error instanceof Error) || !error.message.includes("duplicate column name")) {
37
+ throw error;
38
+ }
39
+ }
40
+ });
41
+ return new LocalTaskWorkItemStore(db);
42
+ }
43
+
44
+ describe("LocalTaskWorkItemStore", () => {
45
+ it("persists Markdown activity across store and database instances", async () => {
46
+ const dbPath = path.join(
47
+ tmpdir(),
48
+ `lt-task-work-item-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
49
+ );
50
+ try {
51
+ const firstStore = await openTaskWorkItemStore(dbPath);
52
+ const created = await firstStore.create({
53
+ tenantId: "tenant-1",
54
+ taskId: "task-1",
55
+ action: "activity",
56
+ actor: "agent:a",
57
+ detail: { markdown: "## Persisted update" },
58
+ });
59
+
60
+ closeDatabase();
61
+ const secondStore = await openTaskWorkItemStore(dbPath);
62
+ const persisted = await secondStore.list({
63
+ tenantId: "tenant-1",
64
+ taskId: "task-1",
65
+ order: "desc",
66
+ limit: 100,
67
+ });
68
+
69
+ expect(persisted).toHaveLength(1);
70
+ expect(persisted[0]).toMatchObject({
71
+ id: created.id,
72
+ taskId: created.taskId,
73
+ action: "activity",
74
+ detail: { markdown: "## Persisted update" },
75
+ });
76
+ expect(persisted[0].createdAt).toEqual(created.createdAt);
77
+ } finally {
78
+ closeDatabase();
79
+ unlinkSync(dbPath);
80
+ }
81
+ });
82
+
83
+ it("initializes an existing schema idempotently and preserves its event index", async () => {
84
+ const dbPath = path.join(
85
+ tmpdir(),
86
+ `lt-task-work-item-schema-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
87
+ );
88
+ try {
89
+ const db = await initDatabase({ dbPath });
90
+ db.exec(`
91
+ CREATE TABLE lt_task_work_items (
92
+ id TEXT NOT NULL,
93
+ tenant_id TEXT NOT NULL,
94
+ task_id TEXT NOT NULL,
95
+ action TEXT NOT NULL,
96
+ actor TEXT NOT NULL,
97
+ thread_id TEXT,
98
+ summary TEXT,
99
+ detail TEXT,
100
+ attempt INTEGER,
101
+ workspace_id TEXT,
102
+ project_id TEXT,
103
+ event_key TEXT,
104
+ created_at TEXT NOT NULL,
105
+ PRIMARY KEY (tenant_id, id)
106
+ );
107
+ `);
108
+
109
+ new LocalTaskWorkItemStore(db);
110
+ closeDatabase();
111
+ const reopenedDb = await initDatabase({ dbPath });
112
+ const reopenedStore = new LocalTaskWorkItemStore(reopenedDb);
113
+
114
+ const [first, second] = await Promise.all([
115
+ reopenedStore.createIfAbsentByEventKey({
116
+ tenantId: "tenant-1", taskId: "task-1", eventKey: "existing-schema:key",
117
+ action: "completion_evidence", actor: "agent:a",
118
+ }),
119
+ reopenedStore.createIfAbsentByEventKey({
120
+ tenantId: "tenant-1", taskId: "task-1", eventKey: "existing-schema:key",
121
+ action: "completion_evidence", actor: "agent:a",
122
+ }),
123
+ ]);
124
+
125
+ expect(second).toEqual(first);
126
+ } finally {
127
+ closeDatabase();
128
+ unlinkSync(dbPath);
129
+ }
130
+ });
131
+
132
+ it.each([
133
+ [undefined, "DESC"],
134
+ ["asc", "ASC"],
135
+ ["desc", "DESC"],
136
+ ] as const)("uses %s order as %s with deterministic IDs", async (order, sqlOrder) => {
137
+ const all = jest.fn().mockReturnValue(rows);
138
+ const prepare = jest.fn().mockReturnValue({ all });
139
+ const db = { exec: jest.fn(), prepare } as unknown as DatabaseWrapper;
140
+ const store = new LocalTaskWorkItemStore(db);
141
+
142
+ const result = await store.list({
143
+ tenantId: "tenant-1",
144
+ taskId: "task-1",
145
+ limit: 2,
146
+ ...(order && { order }),
147
+ });
148
+
149
+ expect(prepare).toHaveBeenLastCalledWith(expect.stringContaining(
150
+ `ORDER BY created_at ${sqlOrder}, id ${sqlOrder} LIMIT ? OFFSET ?`,
151
+ ));
152
+ expect(all).toHaveBeenCalledWith("tenant-1", "task-1", 2, 0);
153
+ expect(result.map((item) => item.id)).toEqual(["item-1"]);
154
+ });
155
+
156
+ it("filters by workspaceId and projectId", async () => {
157
+ const all = jest.fn().mockReturnValue(rows);
158
+ const prepare = jest.fn().mockReturnValue({ all });
159
+ const db = { exec: jest.fn(), prepare } as unknown as DatabaseWrapper;
160
+ const store = new LocalTaskWorkItemStore(db);
161
+
162
+ await store.list({
163
+ tenantId: "tenant-1",
164
+ taskId: "task-1",
165
+ workspaceId: "workspace-1",
166
+ projectId: "project-1",
167
+ order: "desc",
168
+ limit: 100,
169
+ });
170
+
171
+ expect(prepare).toHaveBeenLastCalledWith(expect.stringContaining(
172
+ "workspace_id = ? AND project_id = ? ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
173
+ ));
174
+ expect(all).toHaveBeenCalledWith(
175
+ "tenant-1", "task-1", "workspace-1", "project-1", 100, 0,
176
+ );
177
+ });
178
+
179
+ it("finds task-scoped event keys and atomically creates duplicates once", async () => {
180
+ const dbPath = path.join(
181
+ tmpdir(),
182
+ `lt-task-work-item-event-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
183
+ );
184
+ try {
185
+ const store = await openTaskWorkItemStore(dbPath);
186
+ const [first, second] = await Promise.all([
187
+ store.createIfAbsentByEventKey({
188
+ tenantId: "tenant-1", taskId: "task-1", eventKey: "evidence:key",
189
+ action: "completion_evidence", actor: "agent:a", summary: "first",
190
+ }),
191
+ store.createIfAbsentByEventKey({
192
+ tenantId: "tenant-1", taskId: "task-1", eventKey: "evidence:key",
193
+ action: "completion_evidence", actor: "agent:a", summary: "second",
194
+ }),
195
+ ]);
196
+ const otherTask = await store.createIfAbsentByEventKey({
197
+ tenantId: "tenant-1", taskId: "task-2", eventKey: "evidence:key",
198
+ action: "completion_evidence", actor: "agent:a",
199
+ });
200
+
201
+ expect(second).toEqual(first);
202
+ expect(first.summary).toBe("first");
203
+ expect(otherTask.id).not.toBe(first.id);
204
+ await expect(store.findByEventKey("tenant-1", "task-1", "evidence:key"))
205
+ .resolves.toEqual(first);
206
+ await expect(store.findByEventKey("tenant-1", "missing", "evidence:key"))
207
+ .resolves.toBeNull();
208
+ await expect(store.list({ tenantId: "tenant-1", taskId: "task-1" }))
209
+ .resolves.toHaveLength(1);
210
+ } finally {
211
+ closeDatabase();
212
+ unlinkSync(dbPath);
213
+ }
214
+ });
215
+ });
package/src/database.ts CHANGED
@@ -62,8 +62,9 @@ export class DatabaseWrapper {
62
62
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
63
  run(sql: string, ...params: any[]): RunResult {
64
64
  this.db.run(sql, params);
65
+ const changes = this.db.getRowsModified();
65
66
  this.save();
66
- return new RunResult(this.db);
67
+ return new RunResult(changes);
67
68
  }
68
69
 
69
70
  /**
@@ -164,10 +165,11 @@ export class StatementWrapper {
164
165
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
166
  run(...params: any[]): RunResult {
166
167
  this.db.run(this.sql, params as BindParams);
168
+ const changes = this.db.getRowsModified();
167
169
  if (this.parent) {
168
170
  this.parent.save();
169
171
  }
170
- return new RunResult(this.db);
172
+ return new RunResult(changes);
171
173
  }
172
174
  }
173
175
 
@@ -175,15 +177,15 @@ export class StatementWrapper {
175
177
  * Result of a run operation.
176
178
  */
177
179
  export class RunResult {
178
- private db: SqlJsDatabase;
180
+ private modifiedRows: number;
179
181
 
180
- constructor(db: SqlJsDatabase) {
181
- this.db = db;
182
+ constructor(modifiedRows: number) {
183
+ this.modifiedRows = modifiedRows;
182
184
  }
183
185
 
184
186
  /** Number of rows modified by the last INSERT/UPDATE/DELETE. */
185
187
  get changes(): number {
186
- return this.db.getRowsModified();
188
+ return this.modifiedRows;
187
189
  }
188
190
  }
189
191
 
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS lt_a2a_api_keys (
19
19
  key_value TEXT NOT NULL,
20
20
  tenant_id TEXT NOT NULL,
21
21
  project_id TEXT,
22
- workspace_id TEXT,
22
+ assistant_ids TEXT,
23
23
  label TEXT,
24
24
  enabled INTEGER NOT NULL DEFAULT 1,
25
25
  created_at TEXT NOT NULL,
@@ -31,8 +31,8 @@ interface KeyRow {
31
31
  id: string;
32
32
  key_value: string;
33
33
  tenant_id: string;
34
- project_id: string | null;
35
- workspace_id: string | null;
34
+ project_id: string;
35
+ assistant_ids: string | null;
36
36
  label: string | null;
37
37
  enabled: number;
38
38
  created_at: string;
@@ -43,12 +43,49 @@ function generateApiKey(): string {
43
43
  return `a2a_${randomUUID().replace(/-/g, "")}`;
44
44
  }
45
45
 
46
+ function parseAssistantIds(raw: string | null): string[] | undefined {
47
+ if (!raw) return undefined;
48
+ try {
49
+ const parsed = JSON.parse(raw);
50
+ return Array.isArray(parsed) ? parsed : undefined;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** SQLite-backed A2A API key persistence for local deployments. */
46
57
  export class LocalA2AApiKeyStore implements A2AApiKeyStore {
47
58
  private db: DatabaseWrapper;
48
59
 
49
60
  constructor(db: DatabaseWrapper) {
50
61
  this.db = db;
51
62
  ensureTable(db, DDL);
63
+ // Existing installs: add columns shipped after the original schema.
64
+ this.ensureColumn("lt_a2a_api_keys", "assistant_ids", "assistant_ids TEXT");
65
+ this.repairManagementIds();
66
+ }
67
+
68
+ /** Add a column if it does not exist (SQLite version compatible). */
69
+ private ensureColumn(table: string, column: string, ddl: string): void {
70
+ const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
71
+ if (!cols.some((c) => c.name === column)) {
72
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
73
+ }
74
+ }
75
+
76
+ private repairManagementIds(): void {
77
+ const rows = this.db.prepare(
78
+ `SELECT rowid, id FROM lt_a2a_api_keys`,
79
+ ).all() as Array<{ rowid: number; id: string | null }>;
80
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
81
+
82
+ for (const row of rows) {
83
+ if (!row.id || !uuidPattern.test(row.id)) {
84
+ this.db.prepare(
85
+ `UPDATE lt_a2a_api_keys SET id = ? WHERE rowid = ?`,
86
+ ).run(randomUUID(), row.rowid);
87
+ }
88
+ }
52
89
  }
53
90
 
54
91
  async findByKey(key: string): Promise<A2AApiKeyRecord | null> {
@@ -63,6 +100,13 @@ export class LocalA2AApiKeyStore implements A2AApiKeyStore {
63
100
  return null;
64
101
  }
65
102
 
103
+ async findById(id: string): Promise<A2AApiKeyRecord | null> {
104
+ const row = this.db.prepare(
105
+ `SELECT * FROM lt_a2a_api_keys WHERE id = ?`,
106
+ ).get(id) as unknown as KeyRow | undefined;
107
+ return row ? mapRowToRecord(row) : null;
108
+ }
109
+
66
110
  async list(params: { tenantId?: string; limit?: number; offset?: number }): Promise<A2AApiKeyRecord[]> {
67
111
  const limit = params.limit || 100;
68
112
  const offset = params.offset || 0;
@@ -80,21 +124,20 @@ export class LocalA2AApiKeyStore implements A2AApiKeyStore {
80
124
  }
81
125
 
82
126
  async create(input: CreateA2AApiKeyInput): Promise<A2AApiKeyRecord> {
127
+ const id = randomUUID();
83
128
  const key = generateApiKey();
84
129
  const now = nowISO();
85
130
  this.db.prepare(
86
- `INSERT INTO lt_a2a_api_keys (key_value, tenant_id, project_id, workspace_id, label, created_at, updated_at)
87
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
88
- ).run(encrypt(key), input.tenantId, input.projectId || null, input.workspaceId || null, input.label || null, now, now);
89
-
90
- const lastId = this.db.prepare(`SELECT last_insert_rowid() as id`).get() as { id: number };
131
+ `INSERT INTO lt_a2a_api_keys (id, key_value, tenant_id, project_id, assistant_ids, label, created_at, updated_at)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
133
+ ).run(id, encrypt(key), input.tenantId, input.projectId, input.assistantIds ? JSON.stringify(input.assistantIds) : null, input.label || null, now, now);
91
134
 
92
135
  const record: A2AApiKeyRecord = {
93
- id: String(lastId.id),
136
+ id,
94
137
  key,
95
138
  tenantId: input.tenantId,
96
139
  projectId: input.projectId,
97
- workspaceId: input.workspaceId,
140
+ assistantIds: input.assistantIds,
98
141
  label: input.label,
99
142
  enabled: true,
100
143
  createdAt: parseISO(now),
@@ -151,8 +194,8 @@ export class LocalA2AApiKeyStore implements A2AApiKeyStore {
151
194
  map.set(key, {
152
195
  key,
153
196
  tenantId: row.tenant_id,
154
- projectId: row.project_id || undefined,
155
- workspaceId: row.workspace_id || undefined,
197
+ projectId: row.project_id,
198
+ assistantIds: parseAssistantIds(row.assistant_ids),
156
199
  });
157
200
  } catch { /* skip */ }
158
201
  }
@@ -167,8 +210,8 @@ function mapRowToRecord(row: KeyRow): A2AApiKeyRecord {
167
210
  id: row.id,
168
211
  key,
169
212
  tenantId: row.tenant_id,
170
- projectId: row.project_id || undefined,
171
- workspaceId: row.workspace_id || undefined,
213
+ projectId: row.project_id,
214
+ assistantIds: parseAssistantIds(row.assistant_ids),
172
215
  label: row.label || undefined,
173
216
  enabled: row.enabled === 1,
174
217
  createdAt: parseISO(row.created_at),
@@ -99,15 +99,7 @@ export class LocalChannelInstallationStore implements ChannelInstallationStore {
99
99
  this.db.prepare(
100
100
  `INSERT INTO lt_channel_installations
101
101
  (id, tenant_id, channel, name, config, enabled, fallback_agent_id, reject_when_no_binding, created_at, updated_at)
102
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
103
- ON CONFLICT(id) DO UPDATE SET
104
- channel = excluded.channel,
105
- name = excluded.name,
106
- config = excluded.config,
107
- enabled = excluded.enabled,
108
- fallback_agent_id = excluded.fallback_agent_id,
109
- reject_when_no_binding = excluded.reject_when_no_binding,
110
- updated_at = excluded.updated_at`,
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
111
103
  ).run(
112
104
  installationId, tenantId, data.channel,
113
105
  data.name || null, JSON.stringify(data.config),
@@ -6,6 +6,7 @@ import { DatabaseWrapper } from "../database";
6
6
  import type {
7
7
  ProjectStore,
8
8
  Project,
9
+ ProjectFilter,
9
10
  CreateProjectRequest,
10
11
  UpdateProjectRequest,
11
12
  } from "@axiom-lattice/protocols";
@@ -19,6 +20,7 @@ CREATE TABLE IF NOT EXISTS lt_projects (
19
20
  name TEXT NOT NULL,
20
21
  description TEXT,
21
22
  config TEXT,
23
+ kind TEXT DEFAULT 'business',
22
24
  created_at TEXT NOT NULL,
23
25
  updated_at TEXT NOT NULL,
24
26
  PRIMARY KEY (tenant_id, id)
@@ -33,6 +35,7 @@ interface ProjectRow {
33
35
  name: string;
34
36
  description: string | null;
35
37
  config: string | null;
38
+ kind: string | null;
36
39
  created_at: string;
37
40
  updated_at: string;
38
41
  }
@@ -43,9 +46,16 @@ export class LocalProjectStore implements ProjectStore {
43
46
  constructor(db: DatabaseWrapper) {
44
47
  this.db = db;
45
48
  ensureTable(db, DDL);
49
+ this.ensureColumn("lt_projects", "kind", "kind TEXT DEFAULT 'business'");
46
50
  }
47
51
 
48
- async getProjectsByWorkspace(tenantId: string, workspaceId: string): Promise<Project[]> {
52
+ async getProjectsByWorkspace(tenantId: string, workspaceId: string, filter?: ProjectFilter): Promise<Project[]> {
53
+ if (filter?.kind !== undefined) {
54
+ const rows = this.db.prepare(
55
+ `SELECT * FROM lt_projects WHERE tenant_id = ? AND workspace_id = ? AND kind = ? ORDER BY created_at DESC`,
56
+ ).all(tenantId, workspaceId, filter.kind) as unknown as ProjectRow[];
57
+ return rows.map(mapRowToProject);
58
+ }
49
59
  const rows = this.db.prepare(
50
60
  `SELECT * FROM lt_projects WHERE tenant_id = ? AND workspace_id = ? ORDER BY created_at DESC`,
51
61
  ).all(tenantId, workspaceId) as unknown as ProjectRow[];
@@ -66,17 +76,19 @@ export class LocalProjectStore implements ProjectStore {
66
76
  data: CreateProjectRequest,
67
77
  ): Promise<Project> {
68
78
  const now = nowISO();
79
+ const kind = data.kind || "business";
69
80
 
70
81
  this.db.prepare(
71
- `INSERT INTO lt_projects (id, tenant_id, workspace_id, name, description, config, created_at, updated_at)
72
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
82
+ `INSERT INTO lt_projects (id, tenant_id, workspace_id, name, description, config, kind, created_at, updated_at)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
73
84
  ON CONFLICT(tenant_id, id) DO UPDATE SET
74
85
  workspace_id = excluded.workspace_id,
75
86
  name = excluded.name,
76
87
  description = excluded.description,
77
88
  config = excluded.config,
89
+ kind = excluded.kind,
78
90
  updated_at = excluded.updated_at`,
79
- ).run(id, tenantId, workspaceId, data.name, data.description || null, data.config ? JSON.stringify(data.config) : null, now, now);
91
+ ).run(id, tenantId, workspaceId, data.name, data.description || null, data.config ? JSON.stringify(data.config) : null, kind, now, now);
80
92
 
81
93
  return {
82
94
  id,
@@ -85,6 +97,7 @@ export class LocalProjectStore implements ProjectStore {
85
97
  name: data.name,
86
98
  description: data.description,
87
99
  config: data.config,
100
+ kind,
88
101
  createdAt: parseISO(now),
89
102
  updatedAt: parseISO(now),
90
103
  };
@@ -113,6 +126,10 @@ export class LocalProjectStore implements ProjectStore {
113
126
  setClauses.push("config = ?");
114
127
  values.push(updates.config ? JSON.stringify(updates.config) : null);
115
128
  }
129
+ if (updates.kind !== undefined) {
130
+ setClauses.push("kind = ?");
131
+ values.push(updates.kind);
132
+ }
116
133
 
117
134
  if (setClauses.length === 0) return existing;
118
135
 
@@ -134,6 +151,14 @@ export class LocalProjectStore implements ProjectStore {
134
151
  ).run(tenantId, id);
135
152
  return result.changes > 0;
136
153
  }
154
+
155
+ /** Add a column if it does not exist (SQLite version compatible). */
156
+ private ensureColumn(table: string, column: string, ddl: string): void {
157
+ const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{ name: string }>;
158
+ if (!cols.some((c) => c.name === column)) {
159
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
160
+ }
161
+ }
137
162
  }
138
163
 
139
164
  function mapRowToProject(row: ProjectRow): Project {
@@ -144,6 +169,7 @@ function mapRowToProject(row: ProjectRow): Project {
144
169
  name: row.name,
145
170
  description: row.description || undefined,
146
171
  config: row.config ? JSON.parse(row.config) as Record<string, unknown> : undefined,
172
+ kind: (row.kind || "business") as Project["kind"],
147
173
  createdAt: parseISO(row.created_at),
148
174
  updatedAt: parseISO(row.updated_at),
149
175
  };