@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +57 -0
- package/dist/index.d.mts +33 -4
- package/dist/index.d.ts +33 -4
- package/dist/index.js +515 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +515 -39
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalA2AApiKeyStore.test.ts +119 -0
- package/src/__tests__/LocalChannelInstallationStore.test.ts +38 -0
- package/src/__tests__/LocalProjectStore.test.ts +127 -0
- package/src/__tests__/LocalTaskStore.test.ts +284 -2
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +215 -0
- package/src/database.ts +8 -6
- package/src/stores/LocalA2AApiKeyStore.ts +57 -14
- package/src/stores/LocalChannelInstallationStore.ts +1 -9
- package/src/stores/LocalProjectStore.ts +30 -4
- package/src/stores/LocalTaskStore.ts +208 -7
- package/src/stores/LocalTaskWorkItemStore.ts +47 -3
|
@@ -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(
|
|
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(
|
|
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
|
|
180
|
+
private modifiedRows: number;
|
|
179
181
|
|
|
180
|
-
constructor(
|
|
181
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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,
|
|
87
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
88
|
-
).run(encrypt(key), input.tenantId, input.projectId
|
|
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
|
|
136
|
+
id,
|
|
94
137
|
key,
|
|
95
138
|
tenantId: input.tenantId,
|
|
96
139
|
projectId: input.projectId,
|
|
97
|
-
|
|
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
|
|
155
|
-
|
|
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
|
|
171
|
-
|
|
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
|
};
|