@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.
@@ -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[] = ['updated_at = ?'];
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) { setClauses.push('context = ?'); values.push(JSON.stringify(updates.context)); }
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.db.exec(`ALTER TABLE lt_task_work_items ADD COLUMN workspace_id TEXT;`);
72
- this.db.exec(`ALTER TABLE lt_task_work_items ADD COLUMN project_id TEXT;`);
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 DESC LIMIT ? OFFSET ?`,
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
  }