@axiom-lattice/local-stores 2.0.10 → 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 +50 -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
|
@@ -14,6 +14,13 @@ import type {
|
|
|
14
14
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
15
15
|
import { randomUUID } from "crypto";
|
|
16
16
|
|
|
17
|
+
const MAX_CANONICAL_TASK_TIME = "9999-12-31T23:59:59.999Z";
|
|
18
|
+
const NEXT_UPDATED_AT_SQL = `updated_at = CASE
|
|
19
|
+
WHEN strftime('%Y-%m-%dT%H:%M:%fZ', updated_at) = updated_at AND updated_at >= ?
|
|
20
|
+
THEN strftime('%Y-%m-%dT%H:%M:%fZ', updated_at, '+0.001 seconds')
|
|
21
|
+
ELSE ?
|
|
22
|
+
END`;
|
|
23
|
+
|
|
17
24
|
const DDL = `
|
|
18
25
|
CREATE TABLE IF NOT EXISTS lt_tasks (
|
|
19
26
|
id TEXT NOT NULL,
|
|
@@ -105,6 +112,11 @@ function mapRowToTask(row: TaskRow): TaskItem {
|
|
|
105
112
|
};
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
function nextISO(previous: Date | string): string {
|
|
116
|
+
const previousMs = new Date(previous).getTime();
|
|
117
|
+
return new Date(Math.max(Date.now(), previousMs + 1)).toISOString();
|
|
118
|
+
}
|
|
119
|
+
|
|
108
120
|
export class LocalTaskStore implements TaskStore {
|
|
109
121
|
private db: DatabaseWrapper;
|
|
110
122
|
|
|
@@ -134,7 +146,7 @@ export class LocalTaskStore implements TaskStore {
|
|
|
134
146
|
async create(
|
|
135
147
|
params: CreateTaskRequest & { tenantId: string; ownerType: string; ownerId: string },
|
|
136
148
|
): Promise<TaskItem> {
|
|
137
|
-
const id = randomUUID();
|
|
149
|
+
const id = params.id ?? randomUUID();
|
|
138
150
|
const now = nowISO();
|
|
139
151
|
|
|
140
152
|
this.db.prepare(
|
|
@@ -219,8 +231,8 @@ export class LocalTaskStore implements TaskStore {
|
|
|
219
231
|
if (!existing) return null;
|
|
220
232
|
|
|
221
233
|
const now = nowISO();
|
|
222
|
-
const setClauses: string[] = [
|
|
223
|
-
const values: unknown[] = [now];
|
|
234
|
+
const setClauses: string[] = [NEXT_UPDATED_AT_SQL];
|
|
235
|
+
const values: unknown[] = [now, now];
|
|
224
236
|
|
|
225
237
|
if (updates.title !== undefined) { setClauses.push('title = ?'); values.push(updates.title); }
|
|
226
238
|
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
|
|
@@ -230,7 +242,9 @@ export class LocalTaskStore implements TaskStore {
|
|
|
230
242
|
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
|
|
231
243
|
if (updates.parentId !== undefined) { setClauses.push('parent_id = ?'); values.push(updates.parentId); }
|
|
232
244
|
if (updates.sourceId !== undefined) { setClauses.push('source_id = ?'); values.push(updates.sourceId); }
|
|
233
|
-
if (updates.context !== undefined) {
|
|
245
|
+
if (updates.context !== undefined) {
|
|
246
|
+
setClauses.push('context = ?'); values.push(updates.context === null ? null : JSON.stringify(updates.context));
|
|
247
|
+
}
|
|
234
248
|
if (updates.ownerType !== undefined) { setClauses.push('owner_type = ?'); values.push(updates.ownerType); }
|
|
235
249
|
if (updates.ownerId !== undefined) { setClauses.push('owner_id = ?'); values.push(updates.ownerId); }
|
|
236
250
|
if (updates.requireReview !== undefined) { setClauses.push('require_review = ?'); values.push(updates.requireReview ? 1 : 0); }
|
|
@@ -243,11 +257,198 @@ export class LocalTaskStore implements TaskStore {
|
|
|
243
257
|
|
|
244
258
|
if (setClauses.length === 1) return existing;
|
|
245
259
|
|
|
246
|
-
values.push(tenantId, id);
|
|
247
|
-
this.db.prepare(
|
|
248
|
-
`UPDATE lt_tasks SET ${setClauses.join(', ')} WHERE tenant_id = ? AND id = ?`,
|
|
260
|
+
values.push(tenantId, id, MAX_CANONICAL_TASK_TIME);
|
|
261
|
+
const result = this.db.prepare(
|
|
262
|
+
`UPDATE lt_tasks SET ${setClauses.join(', ')} WHERE tenant_id = ? AND id = ? AND updated_at <> ?`,
|
|
263
|
+
).run(...values);
|
|
264
|
+
if (result.changes === 0) return null;
|
|
265
|
+
|
|
266
|
+
return this.getById(tenantId, id);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Atomically update a task unless its current status is blocked.
|
|
271
|
+
*
|
|
272
|
+
* @param tenantId Tenant identifier.
|
|
273
|
+
* @param id Task identifier.
|
|
274
|
+
* @param updates Partial task data to update.
|
|
275
|
+
* @param blockedStatuses Current statuses that prevent the update.
|
|
276
|
+
* @returns The updated task, or `null` when missing or blocked.
|
|
277
|
+
*/
|
|
278
|
+
async updateIfStatusNotIn(
|
|
279
|
+
tenantId: string,
|
|
280
|
+
id: string,
|
|
281
|
+
updates: UpdateTaskRequest,
|
|
282
|
+
blockedStatuses: TaskItem["status"][],
|
|
283
|
+
): Promise<TaskItem | null> {
|
|
284
|
+
const now = nowISO();
|
|
285
|
+
const setClauses: string[] = [NEXT_UPDATED_AT_SQL];
|
|
286
|
+
const values: unknown[] = [now, now];
|
|
287
|
+
|
|
288
|
+
if (updates.title !== undefined) { setClauses.push('title = ?'); values.push(updates.title); }
|
|
289
|
+
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
|
|
290
|
+
if (updates.status !== undefined) { setClauses.push('status = ?'); values.push(updates.status); }
|
|
291
|
+
if (updates.priority !== undefined) { setClauses.push('priority = ?'); values.push(updates.priority); }
|
|
292
|
+
if (updates.dueDate !== undefined) { setClauses.push('due_date = ?'); values.push(updates.dueDate); }
|
|
293
|
+
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
|
|
294
|
+
if (updates.parentId !== undefined) { setClauses.push('parent_id = ?'); values.push(updates.parentId); }
|
|
295
|
+
if (updates.sourceId !== undefined) { setClauses.push('source_id = ?'); values.push(updates.sourceId); }
|
|
296
|
+
if (updates.context !== undefined) {
|
|
297
|
+
setClauses.push('context = ?'); values.push(updates.context === null ? null : JSON.stringify(updates.context));
|
|
298
|
+
}
|
|
299
|
+
if (updates.ownerType !== undefined) { setClauses.push('owner_type = ?'); values.push(updates.ownerType); }
|
|
300
|
+
if (updates.ownerId !== undefined) { setClauses.push('owner_id = ?'); values.push(updates.ownerId); }
|
|
301
|
+
if (updates.requireReview !== undefined) { setClauses.push('require_review = ?'); values.push(updates.requireReview ? 1 : 0); }
|
|
302
|
+
if (updates.dependencies !== undefined) { setClauses.push('dependencies = ?'); values.push(JSON.stringify(updates.dependencies)); }
|
|
303
|
+
if (updates.result !== undefined) { setClauses.push('result = ?'); values.push(updates.result); }
|
|
304
|
+
if (updates.failureReason !== undefined) { setClauses.push('failure_reason = ?'); values.push(updates.failureReason); }
|
|
305
|
+
if (updates.workspaceId !== undefined) { setClauses.push('workspace_id = ?'); values.push(updates.workspaceId); }
|
|
306
|
+
if (updates.projectId !== undefined) { setClauses.push('project_id = ?'); values.push(updates.projectId); }
|
|
307
|
+
if (updates.files !== undefined) { setClauses.push('files = ?'); values.push(JSON.stringify(updates.files)); }
|
|
308
|
+
|
|
309
|
+
const statusClause = blockedStatuses.length > 0
|
|
310
|
+
? ` AND status NOT IN (${blockedStatuses.map(() => "?").join(", ")})`
|
|
311
|
+
: "";
|
|
312
|
+
values.push(tenantId, id, MAX_CANONICAL_TASK_TIME, ...blockedStatuses);
|
|
313
|
+
const result = this.db.prepare(
|
|
314
|
+
`UPDATE lt_tasks SET ${setClauses.join(', ')} WHERE tenant_id = ? AND id = ? AND updated_at <> ?${statusClause}`,
|
|
315
|
+
).run(...values);
|
|
316
|
+
if (result.changes === 0) return null;
|
|
317
|
+
return this.getById(tenantId, id);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Atomically update a task only when its current status is expected. */
|
|
321
|
+
async updateIfStatusIn(
|
|
322
|
+
tenantId: string,
|
|
323
|
+
id: string,
|
|
324
|
+
updates: UpdateTaskRequest,
|
|
325
|
+
expectedStatuses: TaskItem["status"][],
|
|
326
|
+
): Promise<TaskItem | null> {
|
|
327
|
+
if (expectedStatuses.length === 0) return null;
|
|
328
|
+
|
|
329
|
+
const now = nowISO();
|
|
330
|
+
const setClauses: string[] = [NEXT_UPDATED_AT_SQL];
|
|
331
|
+
const values: unknown[] = [now, now];
|
|
332
|
+
if (updates.title !== undefined) { setClauses.push('title = ?'); values.push(updates.title); }
|
|
333
|
+
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
|
|
334
|
+
if (updates.status !== undefined) { setClauses.push('status = ?'); values.push(updates.status); }
|
|
335
|
+
if (updates.priority !== undefined) { setClauses.push('priority = ?'); values.push(updates.priority); }
|
|
336
|
+
if (updates.dueDate !== undefined) { setClauses.push('due_date = ?'); values.push(updates.dueDate); }
|
|
337
|
+
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
|
|
338
|
+
if (updates.parentId !== undefined) { setClauses.push('parent_id = ?'); values.push(updates.parentId); }
|
|
339
|
+
if (updates.sourceId !== undefined) { setClauses.push('source_id = ?'); values.push(updates.sourceId); }
|
|
340
|
+
if (updates.context !== undefined) {
|
|
341
|
+
setClauses.push('context = ?'); values.push(updates.context === null ? null : JSON.stringify(updates.context));
|
|
342
|
+
}
|
|
343
|
+
if (updates.ownerType !== undefined) { setClauses.push('owner_type = ?'); values.push(updates.ownerType); }
|
|
344
|
+
if (updates.ownerId !== undefined) { setClauses.push('owner_id = ?'); values.push(updates.ownerId); }
|
|
345
|
+
if (updates.requireReview !== undefined) { setClauses.push('require_review = ?'); values.push(updates.requireReview ? 1 : 0); }
|
|
346
|
+
if (updates.dependencies !== undefined) { setClauses.push('dependencies = ?'); values.push(JSON.stringify(updates.dependencies)); }
|
|
347
|
+
if (updates.result !== undefined) { setClauses.push('result = ?'); values.push(updates.result); }
|
|
348
|
+
if (updates.failureReason !== undefined) { setClauses.push('failure_reason = ?'); values.push(updates.failureReason); }
|
|
349
|
+
if (updates.workspaceId !== undefined) { setClauses.push('workspace_id = ?'); values.push(updates.workspaceId); }
|
|
350
|
+
if (updates.projectId !== undefined) { setClauses.push('project_id = ?'); values.push(updates.projectId); }
|
|
351
|
+
if (updates.files !== undefined) { setClauses.push('files = ?'); values.push(JSON.stringify(updates.files)); }
|
|
352
|
+
|
|
353
|
+
values.push(tenantId, id, MAX_CANONICAL_TASK_TIME, ...expectedStatuses);
|
|
354
|
+
const result = this.db.prepare(
|
|
355
|
+
`UPDATE lt_tasks SET ${setClauses.join(', ')} WHERE tenant_id = ? AND id = ? AND updated_at <> ? AND status IN (${expectedStatuses.map(() => "?").join(", ")})`,
|
|
249
356
|
).run(...values);
|
|
357
|
+
if (result.changes === 0) return null;
|
|
358
|
+
return this.getById(tenantId, id);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Atomically update a task only when status and updatedAt match a read snapshot. */
|
|
362
|
+
async updateIfStatusAndUpdatedAt(
|
|
363
|
+
tenantId: string,
|
|
364
|
+
id: string,
|
|
365
|
+
updates: UpdateTaskRequest,
|
|
366
|
+
expectedStatuses: TaskItem["status"][],
|
|
367
|
+
expectedUpdatedAt: Date | string,
|
|
368
|
+
): Promise<TaskItem | null> {
|
|
369
|
+
if (expectedStatuses.length === 0) return null;
|
|
370
|
+
const expectedIso = new Date(expectedUpdatedAt).toISOString();
|
|
371
|
+
if (expectedIso === MAX_CANONICAL_TASK_TIME) return null;
|
|
372
|
+
const setClauses: string[] = ["updated_at = ?"];
|
|
373
|
+
const values: unknown[] = [nextISO(expectedIso)];
|
|
374
|
+
if (updates.title !== undefined) { setClauses.push('title = ?'); values.push(updates.title); }
|
|
375
|
+
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
|
|
376
|
+
if (updates.status !== undefined) { setClauses.push('status = ?'); values.push(updates.status); }
|
|
377
|
+
if (updates.priority !== undefined) { setClauses.push('priority = ?'); values.push(updates.priority); }
|
|
378
|
+
if (updates.dueDate !== undefined) { setClauses.push('due_date = ?'); values.push(updates.dueDate); }
|
|
379
|
+
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
|
|
380
|
+
if (updates.parentId !== undefined) { setClauses.push('parent_id = ?'); values.push(updates.parentId); }
|
|
381
|
+
if (updates.sourceId !== undefined) { setClauses.push('source_id = ?'); values.push(updates.sourceId); }
|
|
382
|
+
if (updates.context !== undefined) {
|
|
383
|
+
setClauses.push('context = ?'); values.push(updates.context === null ? null : JSON.stringify(updates.context));
|
|
384
|
+
}
|
|
385
|
+
if (updates.ownerType !== undefined) { setClauses.push('owner_type = ?'); values.push(updates.ownerType); }
|
|
386
|
+
if (updates.ownerId !== undefined) { setClauses.push('owner_id = ?'); values.push(updates.ownerId); }
|
|
387
|
+
if (updates.requireReview !== undefined) { setClauses.push('require_review = ?'); values.push(updates.requireReview ? 1 : 0); }
|
|
388
|
+
if (updates.dependencies !== undefined) { setClauses.push('dependencies = ?'); values.push(JSON.stringify(updates.dependencies)); }
|
|
389
|
+
if (updates.result !== undefined) { setClauses.push('result = ?'); values.push(updates.result); }
|
|
390
|
+
if (updates.failureReason !== undefined) { setClauses.push('failure_reason = ?'); values.push(updates.failureReason); }
|
|
391
|
+
if (updates.workspaceId !== undefined) { setClauses.push('workspace_id = ?'); values.push(updates.workspaceId); }
|
|
392
|
+
if (updates.projectId !== undefined) { setClauses.push('project_id = ?'); values.push(updates.projectId); }
|
|
393
|
+
if (updates.files !== undefined) { setClauses.push('files = ?'); values.push(JSON.stringify(updates.files)); }
|
|
250
394
|
|
|
395
|
+
values.push(tenantId, id, ...expectedStatuses, expectedIso);
|
|
396
|
+
const result = this.db.prepare(
|
|
397
|
+
`UPDATE lt_tasks SET ${setClauses.join(', ')} WHERE tenant_id = ? AND id = ? AND status IN (${expectedStatuses.map(() => "?").join(", ")}) AND updated_at = ?`,
|
|
398
|
+
).run(...values);
|
|
399
|
+
if (result.changes === 0) return null;
|
|
400
|
+
return this.getById(tenantId, id);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Atomically update a child only when both child and parent snapshots match. */
|
|
404
|
+
async updateIfStatusUpdatedAtAndParentUpdatedAt(
|
|
405
|
+
tenantId: string,
|
|
406
|
+
id: string,
|
|
407
|
+
updates: UpdateTaskRequest,
|
|
408
|
+
expectedStatuses: TaskItem["status"][],
|
|
409
|
+
expectedUpdatedAt: Date | string,
|
|
410
|
+
parentId: string,
|
|
411
|
+
expectedParentUpdatedAt: Date | string,
|
|
412
|
+
): Promise<TaskItem | null> {
|
|
413
|
+
if (expectedStatuses.length === 0) return null;
|
|
414
|
+
const expectedIso = new Date(expectedUpdatedAt).toISOString();
|
|
415
|
+
if (expectedIso === MAX_CANONICAL_TASK_TIME) return null;
|
|
416
|
+
const parentExpectedIso = new Date(expectedParentUpdatedAt).toISOString();
|
|
417
|
+
const setClauses: string[] = ["updated_at = ?"];
|
|
418
|
+
const values: unknown[] = [nextISO(expectedIso)];
|
|
419
|
+
if (updates.title !== undefined) { setClauses.push('title = ?'); values.push(updates.title); }
|
|
420
|
+
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
|
|
421
|
+
if (updates.status !== undefined) { setClauses.push('status = ?'); values.push(updates.status); }
|
|
422
|
+
if (updates.priority !== undefined) { setClauses.push('priority = ?'); values.push(updates.priority); }
|
|
423
|
+
if (updates.dueDate !== undefined) { setClauses.push('due_date = ?'); values.push(updates.dueDate); }
|
|
424
|
+
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
|
|
425
|
+
if (updates.parentId !== undefined) { setClauses.push('parent_id = ?'); values.push(updates.parentId); }
|
|
426
|
+
if (updates.sourceId !== undefined) { setClauses.push('source_id = ?'); values.push(updates.sourceId); }
|
|
427
|
+
if (updates.context !== undefined) {
|
|
428
|
+
setClauses.push('context = ?'); values.push(updates.context === null ? null : JSON.stringify(updates.context));
|
|
429
|
+
}
|
|
430
|
+
if (updates.ownerType !== undefined) { setClauses.push('owner_type = ?'); values.push(updates.ownerType); }
|
|
431
|
+
if (updates.ownerId !== undefined) { setClauses.push('owner_id = ?'); values.push(updates.ownerId); }
|
|
432
|
+
if (updates.requireReview !== undefined) { setClauses.push('require_review = ?'); values.push(updates.requireReview ? 1 : 0); }
|
|
433
|
+
if (updates.dependencies !== undefined) { setClauses.push('dependencies = ?'); values.push(JSON.stringify(updates.dependencies)); }
|
|
434
|
+
if (updates.result !== undefined) { setClauses.push('result = ?'); values.push(updates.result); }
|
|
435
|
+
if (updates.failureReason !== undefined) { setClauses.push('failure_reason = ?'); values.push(updates.failureReason); }
|
|
436
|
+
if (updates.workspaceId !== undefined) { setClauses.push('workspace_id = ?'); values.push(updates.workspaceId); }
|
|
437
|
+
if (updates.projectId !== undefined) { setClauses.push('project_id = ?'); values.push(updates.projectId); }
|
|
438
|
+
if (updates.files !== undefined) { setClauses.push('files = ?'); values.push(JSON.stringify(updates.files)); }
|
|
439
|
+
|
|
440
|
+
values.push(tenantId, id, ...expectedStatuses, expectedIso, parentId, parentExpectedIso);
|
|
441
|
+
const result = this.db.prepare(
|
|
442
|
+
`UPDATE lt_tasks AS child SET ${setClauses.join(', ')}
|
|
443
|
+
WHERE tenant_id = ? AND id = ?
|
|
444
|
+
AND status IN (${expectedStatuses.map(() => "?").join(", ")})
|
|
445
|
+
AND updated_at = ?
|
|
446
|
+
AND EXISTS (
|
|
447
|
+
SELECT 1 FROM lt_tasks AS parent
|
|
448
|
+
WHERE parent.tenant_id = child.tenant_id AND parent.id = ? AND parent.updated_at = ?
|
|
449
|
+
)`,
|
|
450
|
+
).run(...values);
|
|
451
|
+
if (result.changes === 0) return null;
|
|
251
452
|
return this.getById(tenantId, id);
|
|
252
453
|
}
|
|
253
454
|
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
TaskWorkItemStore,
|
|
8
8
|
TaskWorkItem,
|
|
9
9
|
CreateWorkItemRequest,
|
|
10
|
+
CreateWorkItemIfAbsentRequest,
|
|
10
11
|
TaskWorkItemListFilter,
|
|
11
12
|
} from "@axiom-lattice/protocols";
|
|
12
13
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
@@ -42,6 +43,7 @@ interface WorkItemRow {
|
|
|
42
43
|
attempt: number | null;
|
|
43
44
|
workspace_id: string | null;
|
|
44
45
|
project_id: string | null;
|
|
46
|
+
event_key: string | null;
|
|
45
47
|
created_at: string;
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -58,6 +60,7 @@ function mapRowToWorkItem(row: WorkItemRow): TaskWorkItem {
|
|
|
58
60
|
attempt: row.attempt ?? undefined,
|
|
59
61
|
workspaceId: row.workspace_id ?? undefined,
|
|
60
62
|
projectId: row.project_id ?? undefined,
|
|
63
|
+
eventKey: row.event_key ?? undefined,
|
|
61
64
|
createdAt: parseISO(row.created_at),
|
|
62
65
|
};
|
|
63
66
|
}
|
|
@@ -68,8 +71,19 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
68
71
|
constructor(db: DatabaseWrapper) {
|
|
69
72
|
this.db = db;
|
|
70
73
|
ensureTable(db, DDL);
|
|
71
|
-
this.
|
|
72
|
-
this.
|
|
74
|
+
this.ensureColumn("lt_task_work_items", "workspace_id", "workspace_id TEXT");
|
|
75
|
+
this.ensureColumn("lt_task_work_items", "project_id", "project_id TEXT");
|
|
76
|
+
this.ensureColumn("lt_task_work_items", "event_key", "event_key TEXT");
|
|
77
|
+
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_task_work_items_event_key
|
|
78
|
+
ON lt_task_work_items (tenant_id, task_id, event_key);`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Add a column if it does not exist (SQLite version compatible). */
|
|
82
|
+
private ensureColumn(table: string, column: string, ddl: string): void {
|
|
83
|
+
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
84
|
+
if (!cols.some((c) => c.name === column)) {
|
|
85
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
86
|
+
}
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
async create(params: CreateWorkItemRequest): Promise<TaskWorkItem> {
|
|
@@ -95,18 +109,48 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
95
109
|
return mapRowToWorkItem(row!);
|
|
96
110
|
}
|
|
97
111
|
|
|
112
|
+
/** Find an event by its tenant- and task-scoped key without pagination. */
|
|
113
|
+
async findByEventKey(tenantId: string, taskId: string, eventKey: string): Promise<TaskWorkItem | null> {
|
|
114
|
+
const row = this.db.prepare(
|
|
115
|
+
`SELECT * FROM lt_task_work_items WHERE tenant_id = ? AND task_id = ? AND event_key = ?`,
|
|
116
|
+
).get(tenantId, taskId, eventKey) as unknown as WorkItemRow | undefined;
|
|
117
|
+
return row ? mapRowToWorkItem(row) : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Atomically return an existing event or create it once. */
|
|
121
|
+
async createIfAbsentByEventKey(params: CreateWorkItemIfAbsentRequest): Promise<TaskWorkItem> {
|
|
122
|
+
const id = uuid();
|
|
123
|
+
this.db.prepare(
|
|
124
|
+
`INSERT INTO lt_task_work_items (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, event_key, created_at)
|
|
125
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
126
|
+
ON CONFLICT (tenant_id, task_id, event_key) DO NOTHING`,
|
|
127
|
+
).run(
|
|
128
|
+
id, params.tenantId, params.taskId, params.action, params.actor,
|
|
129
|
+
params.threadId || null, params.summary || null,
|
|
130
|
+
params.detail ? JSON.stringify(params.detail) : null,
|
|
131
|
+
params.attempt ?? null, params.workspaceId || null, params.projectId || null,
|
|
132
|
+
params.eventKey, nowISO(),
|
|
133
|
+
);
|
|
134
|
+
const item = await this.findByEventKey(params.tenantId, params.taskId, params.eventKey);
|
|
135
|
+
if (!item) throw new Error("Task work item insert did not return a row");
|
|
136
|
+
return item;
|
|
137
|
+
}
|
|
138
|
+
|
|
98
139
|
async list(filter: TaskWorkItemListFilter): Promise<TaskWorkItem[]> {
|
|
99
140
|
const conditions: string[] = ['tenant_id = ?', 'task_id = ?'];
|
|
100
141
|
const params: unknown[] = [filter.tenantId, filter.taskId];
|
|
101
142
|
|
|
102
143
|
if (filter.action) { conditions.push('action = ?'); params.push(filter.action); }
|
|
144
|
+
if (filter.workspaceId) { conditions.push('workspace_id = ?'); params.push(filter.workspaceId); }
|
|
145
|
+
if (filter.projectId) { conditions.push('project_id = ?'); params.push(filter.projectId); }
|
|
103
146
|
|
|
104
147
|
const where = conditions.join(' AND ');
|
|
105
148
|
const limit = filter.limit || 100;
|
|
106
149
|
const offset = filter.offset || 0;
|
|
150
|
+
const order = filter.order === 'asc' ? 'ASC' : 'DESC';
|
|
107
151
|
|
|
108
152
|
const rows = this.db.prepare(
|
|
109
|
-
`SELECT * FROM lt_task_work_items WHERE ${where} ORDER BY created_at
|
|
153
|
+
`SELECT * FROM lt_task_work_items WHERE ${where} ORDER BY created_at ${order}, id ${order} LIMIT ? OFFSET ?`,
|
|
110
154
|
).all(...params, limit, offset) as unknown as WorkItemRow[];
|
|
111
155
|
return rows.map(mapRowToWorkItem);
|
|
112
156
|
}
|