@axiom-lattice/local-stores 3.1.0 → 3.1.2
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 +18 -0
- package/dist/index.d.mts +193 -22
- package/dist/index.d.ts +193 -22
- package/dist/index.js +1482 -242
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1479 -242
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalCapabilityBundleStore.test.ts +216 -0
- package/src/__tests__/LocalChannelBindingStore.test.ts +183 -0
- package/src/__tests__/LocalChannelInstallationStore.test.ts +23 -0
- package/src/__tests__/LocalProjectRoomStores.test.ts +356 -0
- package/src/__tests__/LocalProjectStore.test.ts +39 -2
- package/src/__tests__/LocalTaskStore.test.ts +58 -0
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +199 -0
- package/src/__tests__/LocalThreadMessageQueueStore.test.ts +201 -0
- package/src/createLocalStoreConfig.ts +10 -0
- package/src/index.ts +5 -0
- package/src/migrations/migration.ts +1 -1
- package/src/stores/LocalCapabilityBundleStore.ts +53 -0
- package/src/stores/LocalChannelBindingStore.ts +75 -41
- package/src/stores/LocalChannelInstallationStore.ts +6 -5
- package/src/stores/LocalProjectBotMembershipStore.ts +105 -0
- package/src/stores/LocalProjectMembershipStore.ts +72 -0
- package/src/stores/LocalProjectRoomMessageStore.ts +63 -0
- package/src/stores/LocalProjectRoomStore.ts +37 -0
- package/src/stores/LocalProjectStore.ts +67 -14
- package/src/stores/LocalTaskStore.ts +82 -2
- package/src/stores/LocalTaskWorkItemStore.ts +136 -1
- package/src/stores/LocalThreadMessageQueueStore.ts +136 -28
- package/src/stores/project-room-schema.ts +243 -0
- package/src/stores/room-channel-binding-index.ts +60 -0
|
@@ -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 =
|
|
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(
|
|
127
|
-
|
|
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,11 +10,16 @@ import type {
|
|
|
10
10
|
CreateTaskRequest,
|
|
11
11
|
UpdateTaskRequest,
|
|
12
12
|
TaskListFilter,
|
|
13
|
+
TaskDependentListQuery,
|
|
14
|
+
TaskMutationSnapshot,
|
|
13
15
|
} from "@axiom-lattice/protocols";
|
|
14
16
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
15
17
|
import { randomUUID } from "crypto";
|
|
16
18
|
|
|
17
19
|
const MAX_CANONICAL_TASK_TIME = "9999-12-31T23:59:59.999Z";
|
|
20
|
+
const TASK_STATUSES: ReadonlySet<string> = new Set([
|
|
21
|
+
"pending", "in_progress", "review", "failed", "interrupted", "completed", "cancelled",
|
|
22
|
+
]);
|
|
18
23
|
const NEXT_UPDATED_AT_SQL = `updated_at = CASE
|
|
19
24
|
WHEN strftime('%Y-%m-%dT%H:%M:%fZ', updated_at) = updated_at AND updated_at >= ?
|
|
20
25
|
THEN strftime('%Y-%m-%dT%H:%M:%fZ', updated_at, '+0.001 seconds')
|
|
@@ -117,6 +122,11 @@ function nextISO(previous: Date | string): string {
|
|
|
117
122
|
return new Date(Math.max(Date.now(), previousMs + 1)).toISOString();
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
function assertPage(limit: number, offset = 0): void {
|
|
126
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100
|
|
127
|
+
|| !Number.isSafeInteger(offset) || offset < 0) throw new RangeError("Invalid task page");
|
|
128
|
+
}
|
|
129
|
+
|
|
120
130
|
export class LocalTaskStore implements TaskStore {
|
|
121
131
|
private db: DatabaseWrapper;
|
|
122
132
|
|
|
@@ -190,7 +200,8 @@ export class LocalTaskStore implements TaskStore {
|
|
|
190
200
|
}
|
|
191
201
|
if (filter.priority) { conditions.push('priority = ?'); params.push(filter.priority); }
|
|
192
202
|
if (filter.workspaceId) { conditions.push('workspace_id = ?'); params.push(filter.workspaceId); }
|
|
193
|
-
if (filter.projectId)
|
|
203
|
+
if (filter.projectId === null) conditions.push("(project_id IS NULL OR project_id = '' OR project_id = 'default')");
|
|
204
|
+
else if (filter.projectId !== undefined) { conditions.push('project_id = ?'); params.push(filter.projectId); }
|
|
194
205
|
if (filter.parentId) { conditions.push('parent_id = ?'); params.push(filter.parentId); }
|
|
195
206
|
if (filter.sourceId) { conditions.push('source_id = ?'); params.push(filter.sourceId); }
|
|
196
207
|
if (filter.metadata) {
|
|
@@ -217,11 +228,39 @@ export class LocalTaskStore implements TaskStore {
|
|
|
217
228
|
const offset = filter.offset || 0;
|
|
218
229
|
|
|
219
230
|
const rows = this.db.prepare(
|
|
220
|
-
`SELECT * FROM lt_tasks WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
231
|
+
`SELECT * FROM lt_tasks WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
|
|
221
232
|
).all(...params, limit, offset) as unknown as TaskRow[];
|
|
222
233
|
return rows.map(mapRowToTask);
|
|
223
234
|
}
|
|
224
235
|
|
|
236
|
+
/** Lists exact project tasks containing a string dependency. */
|
|
237
|
+
async listDependents(query: TaskDependentListQuery): Promise<TaskItem[]> {
|
|
238
|
+
assertPage(query.limit, query.offset);
|
|
239
|
+
if (query.statuses.length === 0 || query.statuses.some((status) => !TASK_STATUSES.has(status))) {
|
|
240
|
+
throw new RangeError("Invalid task statuses");
|
|
241
|
+
}
|
|
242
|
+
const rows = this.db.prepare(
|
|
243
|
+
`SELECT task.* FROM lt_tasks AS task
|
|
244
|
+
WHERE task.tenant_id = ? AND task.workspace_id = ? AND task.project_id = ?
|
|
245
|
+
AND task.status IN (${query.statuses.map(() => "?").join(", ")})
|
|
246
|
+
AND task.dependencies IS NOT NULL
|
|
247
|
+
AND json_valid(task.dependencies) = 1
|
|
248
|
+
AND json_type(CASE WHEN json_valid(task.dependencies)=1
|
|
249
|
+
THEN task.dependencies ELSE '[]' END) = 'array'
|
|
250
|
+
AND EXISTS (
|
|
251
|
+
SELECT 1 FROM json_each(
|
|
252
|
+
CASE WHEN json_valid(task.dependencies)=1 THEN
|
|
253
|
+
CASE WHEN json_type(task.dependencies)='array' THEN task.dependencies ELSE '[]' END
|
|
254
|
+
ELSE '[]' END
|
|
255
|
+
) AS dependency
|
|
256
|
+
WHERE dependency.type = 'text' AND dependency.value = ?
|
|
257
|
+
)
|
|
258
|
+
ORDER BY task.created_at DESC, task.id DESC LIMIT ? OFFSET ?`,
|
|
259
|
+
).all(query.tenantId, query.workspaceId, query.projectId, ...query.statuses,
|
|
260
|
+
query.dependencyTaskId, query.limit, query.offset) as unknown as TaskRow[];
|
|
261
|
+
return rows.map(mapRowToTask);
|
|
262
|
+
}
|
|
263
|
+
|
|
225
264
|
async update(
|
|
226
265
|
tenantId: string,
|
|
227
266
|
id: string,
|
|
@@ -400,6 +439,37 @@ export class LocalTaskStore implements TaskStore {
|
|
|
400
439
|
return this.getById(tenantId, id);
|
|
401
440
|
}
|
|
402
441
|
|
|
442
|
+
/** Atomically update only while status, timestamp, owner, and Project scope match. */
|
|
443
|
+
async updateIfSnapshot(
|
|
444
|
+
tenantId: string, id: string, updates: UpdateTaskRequest, snapshot: TaskMutationSnapshot,
|
|
445
|
+
): Promise<TaskItem | null> {
|
|
446
|
+
const expectedIso = new Date(snapshot.updatedAt).toISOString();
|
|
447
|
+
if (expectedIso === MAX_CANONICAL_TASK_TIME) return null;
|
|
448
|
+
const setClauses = ["updated_at = ?"];
|
|
449
|
+
const values: unknown[] = [nextISO(expectedIso)];
|
|
450
|
+
const fields: Array<[keyof UpdateTaskRequest, string, boolean?]> = [
|
|
451
|
+
["title", "title"], ["description", "description"], ["status", "status"], ["priority", "priority"],
|
|
452
|
+
["dueDate", "due_date"], ["metadata", "metadata", true], ["parentId", "parent_id"],
|
|
453
|
+
["sourceId", "source_id"], ["context", "context", true], ["ownerType", "owner_type"],
|
|
454
|
+
["ownerId", "owner_id"], ["requireReview", "require_review"], ["dependencies", "dependencies", true],
|
|
455
|
+
["result", "result"], ["failureReason", "failure_reason"], ["workspaceId", "workspace_id"],
|
|
456
|
+
["projectId", "project_id"], ["files", "files", true],
|
|
457
|
+
];
|
|
458
|
+
for (const [field, column, json] of fields) {
|
|
459
|
+
const value = updates[field];
|
|
460
|
+
if (value === undefined) continue;
|
|
461
|
+
setClauses.push(`${column} = ?`);
|
|
462
|
+
values.push(json && value !== null ? JSON.stringify(value) : field === "requireReview" ? (value ? 1 : 0) : value);
|
|
463
|
+
}
|
|
464
|
+
values.push(tenantId, id, snapshot.status, expectedIso, snapshot.ownerType, snapshot.ownerId,
|
|
465
|
+
snapshot.workspaceId, snapshot.projectId);
|
|
466
|
+
const result = this.db.prepare(
|
|
467
|
+
`UPDATE lt_tasks SET ${setClauses.join(", ")} WHERE tenant_id = ? AND id = ? AND status = ?
|
|
468
|
+
AND updated_at = ? AND owner_type = ? AND owner_id = ? AND workspace_id IS ? AND project_id IS ?`,
|
|
469
|
+
).run(...values);
|
|
470
|
+
return result.changes === 0 ? null : this.getById(tenantId, id);
|
|
471
|
+
}
|
|
472
|
+
|
|
403
473
|
/** Atomically update a child only when both child and parent snapshots match. */
|
|
404
474
|
async updateIfStatusUpdatedAtAndParentUpdatedAt(
|
|
405
475
|
tenantId: string,
|
|
@@ -458,4 +528,14 @@ export class LocalTaskStore implements TaskStore {
|
|
|
458
528
|
).run(tenantId, id);
|
|
459
529
|
return result.changes > 0;
|
|
460
530
|
}
|
|
531
|
+
|
|
532
|
+
/** Atomically delete only while status, timestamp, owner, and Project scope match. */
|
|
533
|
+
async deleteIfSnapshot(tenantId: string, id: string, snapshot: TaskMutationSnapshot): Promise<boolean> {
|
|
534
|
+
const result = this.db.prepare(
|
|
535
|
+
`DELETE FROM lt_tasks WHERE tenant_id = ? AND id = ? AND status = ? AND updated_at = ?
|
|
536
|
+
AND owner_type = ? AND owner_id = ? AND workspace_id IS ? AND project_id IS ?`,
|
|
537
|
+
).run(tenantId, id, snapshot.status, new Date(snapshot.updatedAt).toISOString(), snapshot.ownerType,
|
|
538
|
+
snapshot.ownerId, snapshot.workspaceId, snapshot.projectId);
|
|
539
|
+
return result.changes > 0;
|
|
540
|
+
}
|
|
461
541
|
}
|
|
@@ -9,10 +9,18 @@ import type {
|
|
|
9
9
|
CreateWorkItemRequest,
|
|
10
10
|
CreateWorkItemIfAbsentRequest,
|
|
11
11
|
TaskWorkItemListFilter,
|
|
12
|
+
ProjectLifecycleEventQuery,
|
|
13
|
+
TaskMutationSnapshot,
|
|
12
14
|
} from "@axiom-lattice/protocols";
|
|
15
|
+
import { MAX_PENDING_EXECUTION_RESULTS_LIMIT } from "@axiom-lattice/protocols";
|
|
13
16
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
14
17
|
import { v4 as uuid } from "uuid";
|
|
15
18
|
|
|
19
|
+
const PROJECT_LIFECYCLE_ACTIONS: ReadonlySet<string> = new Set([
|
|
20
|
+
"in_progress", "interrupted", "failed", "completed", "cancelled", "reassigned",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
|
|
16
24
|
const DDL = `
|
|
17
25
|
CREATE TABLE IF NOT EXISTS lt_task_work_items (
|
|
18
26
|
id TEXT NOT NULL,
|
|
@@ -47,6 +55,18 @@ interface WorkItemRow {
|
|
|
47
55
|
created_at: string;
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
function parseDetail(raw: string | null): Record<string, unknown> | undefined {
|
|
59
|
+
if (!raw) return undefined;
|
|
60
|
+
try {
|
|
61
|
+
const parsed: unknown = JSON.parse(raw);
|
|
62
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
63
|
+
? parsed as Record<string, unknown>
|
|
64
|
+
: undefined;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
function mapRowToWorkItem(row: WorkItemRow): TaskWorkItem {
|
|
51
71
|
return {
|
|
52
72
|
id: row.id,
|
|
@@ -56,7 +76,7 @@ function mapRowToWorkItem(row: WorkItemRow): TaskWorkItem {
|
|
|
56
76
|
actor: row.actor,
|
|
57
77
|
threadId: row.thread_id ?? undefined,
|
|
58
78
|
summary: row.summary ?? undefined,
|
|
59
|
-
detail:
|
|
79
|
+
detail: parseDetail(row.detail),
|
|
60
80
|
attempt: row.attempt ?? undefined,
|
|
61
81
|
workspaceId: row.workspace_id ?? undefined,
|
|
62
82
|
projectId: row.project_id ?? undefined,
|
|
@@ -65,6 +85,33 @@ function mapRowToWorkItem(row: WorkItemRow): TaskWorkItem {
|
|
|
65
85
|
};
|
|
66
86
|
}
|
|
67
87
|
|
|
88
|
+
function assertAndCloneProjectLifecycleEvent(item: TaskWorkItem): TaskWorkItem {
|
|
89
|
+
let milliseconds: number;
|
|
90
|
+
try { milliseconds = Date.prototype.getTime.call(item.createdAt) as number; }
|
|
91
|
+
catch { throw new Error("Invalid project lifecycle event row"); }
|
|
92
|
+
if (!Number.isFinite(milliseconds) || typeof item.id !== "string" || item.id.length === 0
|
|
93
|
+
|| typeof item.eventKey !== "string" || item.eventKey.length === 0) {
|
|
94
|
+
throw new Error("Invalid project lifecycle event row");
|
|
95
|
+
}
|
|
96
|
+
return { ...item, createdAt: new Date(milliseconds) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertLifecyclePage(query: ProjectLifecycleEventQuery): number | null {
|
|
100
|
+
if (!Number.isSafeInteger(query.limit) || query.limit < 1 || query.limit > 100
|
|
101
|
+
|| query.actions.length === 0
|
|
102
|
+
|| query.actions.some((action) => !PROJECT_LIFECYCLE_ACTIONS.has(action))) {
|
|
103
|
+
throw new RangeError("Invalid project lifecycle event page");
|
|
104
|
+
}
|
|
105
|
+
if (!query.before) return null;
|
|
106
|
+
let milliseconds: number;
|
|
107
|
+
try { milliseconds = Date.prototype.getTime.call(query.before.createdAt) as number; }
|
|
108
|
+
catch { throw new RangeError("Invalid project lifecycle event cursor"); }
|
|
109
|
+
if (!Number.isFinite(milliseconds) || typeof query.before.id !== "string" || query.before.id.length === 0) {
|
|
110
|
+
throw new RangeError("Invalid project lifecycle event cursor");
|
|
111
|
+
}
|
|
112
|
+
return milliseconds;
|
|
113
|
+
}
|
|
114
|
+
|
|
68
115
|
export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
69
116
|
private db: DatabaseWrapper;
|
|
70
117
|
|
|
@@ -76,6 +123,14 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
76
123
|
this.ensureColumn("lt_task_work_items", "event_key", "event_key TEXT");
|
|
77
124
|
this.db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_task_work_items_event_key
|
|
78
125
|
ON lt_task_work_items (tenant_id, task_id, event_key);`);
|
|
126
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_task_work_items_pending_order
|
|
127
|
+
ON lt_task_work_items (tenant_id, task_id, action, created_at DESC, id DESC);`);
|
|
128
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_task_work_items_reconciled_result
|
|
129
|
+
ON lt_task_work_items (tenant_id, task_id, json_extract(detail, '$.executionResultId'))
|
|
130
|
+
WHERE action = 'execution_reconciled';`);
|
|
131
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_task_work_items_project_lifecycle
|
|
132
|
+
ON lt_task_work_items (tenant_id, workspace_id, project_id, created_at DESC, id DESC)
|
|
133
|
+
WHERE event_key IS NOT NULL;`);
|
|
79
134
|
}
|
|
80
135
|
|
|
81
136
|
/** Add a column if it does not exist (SQLite version compatible). */
|
|
@@ -109,6 +164,31 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
109
164
|
return mapRowToWorkItem(row!);
|
|
110
165
|
}
|
|
111
166
|
|
|
167
|
+
/** Atomically inserts a work item by selecting one exact task snapshot. */
|
|
168
|
+
async createIfTaskSnapshot(
|
|
169
|
+
params: CreateWorkItemRequest,
|
|
170
|
+
snapshot: TaskMutationSnapshot,
|
|
171
|
+
): Promise<TaskWorkItem | null> {
|
|
172
|
+
const id = uuid();
|
|
173
|
+
const result = this.db.prepare(
|
|
174
|
+
`INSERT INTO lt_task_work_items
|
|
175
|
+
(id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, created_at)
|
|
176
|
+
SELECT ?, task.tenant_id, task.id, ?, ?, ?, ?, ?, ?, task.workspace_id, task.project_id, ?
|
|
177
|
+
FROM lt_tasks AS task
|
|
178
|
+
WHERE task.tenant_id = ? AND task.id = ? AND task.status = ? AND task.updated_at = ?
|
|
179
|
+
AND task.owner_type = ? AND task.owner_id = ? AND task.workspace_id IS ? AND task.project_id IS ?
|
|
180
|
+
AND ? IS task.workspace_id AND ? IS task.project_id`,
|
|
181
|
+
).run(id, params.action, params.actor, params.threadId || null, params.summary || null,
|
|
182
|
+
params.detail ? JSON.stringify(params.detail) : null, params.attempt ?? null, nowISO(),
|
|
183
|
+
params.tenantId, params.taskId, snapshot.status, new Date(snapshot.updatedAt).toISOString(),
|
|
184
|
+
snapshot.ownerType, snapshot.ownerId, snapshot.workspaceId, snapshot.projectId,
|
|
185
|
+
params.workspaceId, params.projectId);
|
|
186
|
+
if (result.changes === 0) return null;
|
|
187
|
+
const row = this.db.prepare(`SELECT * FROM lt_task_work_items WHERE tenant_id = ? AND id = ?`)
|
|
188
|
+
.get(params.tenantId, id) as unknown as WorkItemRow | undefined;
|
|
189
|
+
return row ? mapRowToWorkItem(row) : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
112
192
|
/** Find an event by its tenant- and task-scoped key without pagination. */
|
|
113
193
|
async findByEventKey(tenantId: string, taskId: string, eventKey: string): Promise<TaskWorkItem | null> {
|
|
114
194
|
const row = this.db.prepare(
|
|
@@ -154,4 +234,59 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
154
234
|
).all(...params, limit, offset) as unknown as WorkItemRow[];
|
|
155
235
|
return rows.map(mapRowToWorkItem);
|
|
156
236
|
}
|
|
237
|
+
|
|
238
|
+
/** List pending execution results using one bounded SQLite anti-join query. */
|
|
239
|
+
async listPendingExecutionResults(params: {
|
|
240
|
+
tenantId: string;
|
|
241
|
+
taskId: string;
|
|
242
|
+
limit: number;
|
|
243
|
+
}): Promise<TaskWorkItem[]> {
|
|
244
|
+
if (!Number.isSafeInteger(params.limit)
|
|
245
|
+
|| params.limit < 0
|
|
246
|
+
|| params.limit > MAX_PENDING_EXECUTION_RESULTS_LIMIT) {
|
|
247
|
+
const error = new RangeError(`limit must be a safe integer between 0 and ${MAX_PENDING_EXECUTION_RESULTS_LIMIT}`) as RangeError & {
|
|
248
|
+
code: "INVALID_LIMIT";
|
|
249
|
+
};
|
|
250
|
+
error.code = "INVALID_LIMIT";
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
if (params.limit === 0) return [];
|
|
254
|
+
const rows = this.db.prepare(
|
|
255
|
+
`SELECT result.*
|
|
256
|
+
FROM lt_task_work_items AS result
|
|
257
|
+
WHERE result.tenant_id = ?
|
|
258
|
+
AND result.task_id = ?
|
|
259
|
+
AND result.action = 'execution_result'
|
|
260
|
+
AND substr(result.event_key, 1, 17) = 'execution-result:'
|
|
261
|
+
AND length(result.event_key) > 17
|
|
262
|
+
AND result.event_key NOT GLOB '*[^A-Za-z0-9._:-]*'
|
|
263
|
+
AND NOT EXISTS (
|
|
264
|
+
SELECT 1
|
|
265
|
+
FROM lt_task_work_items AS reconciled
|
|
266
|
+
WHERE reconciled.tenant_id = result.tenant_id
|
|
267
|
+
AND reconciled.task_id = result.task_id
|
|
268
|
+
AND reconciled.action = 'execution_reconciled'
|
|
269
|
+
AND json_extract(reconciled.detail, '$.executionResultId') = result.event_key
|
|
270
|
+
)
|
|
271
|
+
ORDER BY result.created_at DESC, result.id DESC
|
|
272
|
+
LIMIT ?`,
|
|
273
|
+
).all(params.tenantId, params.taskId, params.limit) as unknown as WorkItemRow[];
|
|
274
|
+
return rows.map(mapRowToWorkItem);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Lists canonical project lifecycle events with an exclusive cursor. */
|
|
278
|
+
async listProjectLifecycleEvents(query: ProjectLifecycleEventQuery): Promise<TaskWorkItem[]> {
|
|
279
|
+
const cursorMilliseconds = assertLifecyclePage(query);
|
|
280
|
+
const cursor = cursorMilliseconds === null ? null : new Date(cursorMilliseconds).toISOString();
|
|
281
|
+
const rows = this.db.prepare(
|
|
282
|
+
`SELECT * FROM lt_task_work_items
|
|
283
|
+
WHERE tenant_id = ? AND workspace_id = ? AND project_id = ?
|
|
284
|
+
AND action IN (${query.actions.map(() => "?").join(", ")})
|
|
285
|
+
AND event_key IS NOT NULL AND event_key <> ''
|
|
286
|
+
AND (? IS NULL OR created_at < ? OR (created_at = ? AND id < ?))
|
|
287
|
+
ORDER BY created_at DESC, id DESC LIMIT ?`,
|
|
288
|
+
).all(query.tenantId, query.workspaceId, query.projectId, ...query.actions,
|
|
289
|
+
cursor, cursor, cursor, query.before?.id ?? null, query.limit) as unknown as WorkItemRow[];
|
|
290
|
+
return rows.map(mapRowToWorkItem).map(assertAndCloneProjectLifecycleEvent);
|
|
291
|
+
}
|
|
157
292
|
}
|