@axiom-lattice/local-stores 3.1.1 → 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 +9 -0
- package/dist/index.d.mts +142 -14
- package/dist/index.d.ts +142 -14
- package/dist/index.js +1194 -206
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1189 -203
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalCapabilityBundleStore.test.ts +1 -2
- 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__/LocalTaskStore.test.ts +58 -0
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +92 -0
- package/src/__tests__/LocalThreadMessageQueueStore.test.ts +126 -3
- package/src/createLocalStoreConfig.ts +8 -0
- package/src/index.ts +4 -0
- package/src/migrations/migration.ts +1 -1
- 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/LocalTaskStore.ts +82 -2
- package/src/stores/LocalTaskWorkItemStore.ts +91 -1
- package/src/stores/LocalThreadMessageQueueStore.ts +52 -6
- package/src/stores/project-room-schema.ts +243 -0
- package/src/stores/room-channel-binding-index.ts +60 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ProjectBotMembership, ProjectBotMembershipStatus, ProjectBotMembershipStore, ProjectBotRole } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, isUniqueError, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; room_id: unknown; assistant_id: unknown; role: unknown; title: unknown; responsibility: unknown; mention_name: unknown; status: unknown; room_thread_id: unknown; joined_at: unknown; updated_at: unknown };
|
|
6
|
+
type Conflict = "coordinator_conflict" | "mention_conflict";
|
|
7
|
+
const columns = "id, tenant_id, workspace_id, project_id, room_id, assistant_id, role, title, responsibility, mention_name, status, room_thread_id, joined_at, updated_at";
|
|
8
|
+
|
|
9
|
+
export class ProjectBotMembershipIdConflictError extends Error { constructor(tenantId: string, id: string) { super(`Project bot membership ID '${id}' already exists in tenant '${tenantId}'`); this.name = "ProjectBotMembershipIdConflictError"; } }
|
|
10
|
+
function isRole(value: unknown): value is ProjectBotRole { return value === "coordinator" || value === "specialist"; }
|
|
11
|
+
function isStatus(value: unknown): value is ProjectBotMembershipStatus { return value === "active" || value === "paused" || value === "removed"; }
|
|
12
|
+
function mapRow(row: Row): ProjectBotMembership {
|
|
13
|
+
const joinedAt = typeof row.joined_at === "string" ? new Date(row.joined_at) : undefined;
|
|
14
|
+
const updatedAt = typeof row.updated_at === "string" ? new Date(row.updated_at) : undefined;
|
|
15
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string"
|
|
16
|
+
|| typeof row.project_id !== "string" || typeof row.room_id !== "string" || typeof row.assistant_id !== "string"
|
|
17
|
+
|| !isRole(row.role) || typeof row.title !== "string" || (row.responsibility !== null && typeof row.responsibility !== "string")
|
|
18
|
+
|| typeof row.mention_name !== "string" || !isStatus(row.status) || typeof row.room_thread_id !== "string"
|
|
19
|
+
|| !validDate(joinedAt) || !validDate(updatedAt)) throw new Error("Invalid project bot membership row");
|
|
20
|
+
return { id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id,
|
|
21
|
+
roomId: row.room_id, assistantId: row.assistant_id, role: row.role, title: row.title,
|
|
22
|
+
...(row.responsibility === null ? {} : { responsibility: row.responsibility }), mentionName: row.mention_name,
|
|
23
|
+
status: row.status, roomThreadId: row.room_thread_id, joinedAt, updatedAt };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Persists durable project bot roster memberships in local SQLite. */
|
|
27
|
+
export class LocalProjectBotMembershipStore implements ProjectBotMembershipStore {
|
|
28
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
29
|
+
async list(tenantId: string, projectId: string): Promise<ProjectBotMembership[]> { return (this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? ORDER BY joined_at ASC, id ASC`).all(tenantId, projectId) as Row[]).map(mapRow); }
|
|
30
|
+
async findById(tenantId: string, id: string): Promise<ProjectBotMembership | null> { const row = this.byId(tenantId, id); return row ? mapRow(row) : null; }
|
|
31
|
+
async findByAssistant(tenantId: string, projectId: string, assistantId: string): Promise<ProjectBotMembership | null> { const row = this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND assistant_id = ?`).get(tenantId, projectId, assistantId) as Row | undefined; return row ? mapRow(row) : null; }
|
|
32
|
+
async save(input: Omit<ProjectBotMembership, "joinedAt" | "updatedAt">): Promise<{ kind: "created" | "updated" | "reactivated"; membership: ProjectBotMembership } | { kind: Conflict }> {
|
|
33
|
+
try {
|
|
34
|
+
return transaction(this.db, () => {
|
|
35
|
+
const existing = this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND assistant_id = ?`).get(input.tenantId, input.projectId, input.assistantId) as Row | undefined;
|
|
36
|
+
if (existing) return this.reactivate(existing, input);
|
|
37
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectBotMembershipIdConflictError(input.tenantId, input.id);
|
|
38
|
+
const conflict = this.preflight(input.tenantId, input.projectId, input.roomId, input.role, input.mentionName, input.status, input.id);
|
|
39
|
+
if (conflict) return { kind: conflict };
|
|
40
|
+
const now = timestamp();
|
|
41
|
+
try { write(this.db, "INSERT INTO lt_project_bot_memberships (id, tenant_id, workspace_id, project_id, room_id, assistant_id, role, title, responsibility, mention_name, status, room_thread_id, joined_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, input.assistantId, input.role, input.title, input.responsibility ?? null, input.mentionName, input.status, input.roomThreadId, now, now]); }
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!isUniqueError(error)) throw error;
|
|
44
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectBotMembershipIdConflictError(input.tenantId, input.id);
|
|
45
|
+
const raced = this.byAssistant(input.tenantId, input.projectId, input.assistantId);
|
|
46
|
+
if (raced) return this.reactivate(raced, input);
|
|
47
|
+
const racedConflict = this.preflight(input.tenantId, input.projectId, input.roomId, input.role, input.mentionName, input.status, input.id);
|
|
48
|
+
if (racedConflict) return { kind: racedConflict };
|
|
49
|
+
throw new Error("Unclassified project bot membership uniqueness conflict");
|
|
50
|
+
}
|
|
51
|
+
return { kind: "created", membership: mapRow(this.byId(input.tenantId, input.id) as Row) };
|
|
52
|
+
});
|
|
53
|
+
} catch (error) { const conflict = this.conflict(error); if (conflict) return { kind: conflict }; throw error; }
|
|
54
|
+
}
|
|
55
|
+
async update(input: { tenantId: string; id: string; patch: Partial<Pick<ProjectBotMembership, "role" | "title" | "responsibility" | "mentionName" | "status">>; expectedUpdatedAt: Date }): Promise<{ kind: "updated"; membership: ProjectBotMembership } | { kind: "not_found" | "conflict" | Conflict }> {
|
|
56
|
+
try {
|
|
57
|
+
return transaction(this.db, () => {
|
|
58
|
+
const row = this.byId(input.tenantId, input.id); if (!row) return { kind: "not_found" as const };
|
|
59
|
+
const existing = mapRow(row);
|
|
60
|
+
if (!validDate(input.expectedUpdatedAt) || existing.updatedAt.getTime() !== input.expectedUpdatedAt.getTime()) return { kind: "conflict" as const };
|
|
61
|
+
const candidate = { ...existing, ...input.patch };
|
|
62
|
+
const conflict = this.preflight(candidate.tenantId, candidate.projectId, candidate.roomId, candidate.role, candidate.mentionName, candidate.status, candidate.id);
|
|
63
|
+
if (conflict) return { kind: conflict };
|
|
64
|
+
let changes: number;
|
|
65
|
+
try { changes = write(this.db, "UPDATE lt_project_bot_memberships SET role = ?, title = ?, responsibility = ?, mention_name = ?, status = ?, updated_at = ? WHERE tenant_id = ? AND id = ? AND updated_at = ?", [candidate.role, candidate.title, candidate.responsibility ?? null, candidate.mentionName, candidate.status, timestamp(row.updated_at as string), input.tenantId, input.id, row.updated_at as string]); }
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!isUniqueError(error)) throw error;
|
|
68
|
+
const racedConflict = this.preflight(candidate.tenantId, candidate.projectId, candidate.roomId, candidate.role, candidate.mentionName, candidate.status, candidate.id);
|
|
69
|
+
if (racedConflict) return { kind: racedConflict };
|
|
70
|
+
throw new Error("Unclassified project bot membership uniqueness conflict");
|
|
71
|
+
}
|
|
72
|
+
if (changes === 0) return { kind: "conflict" as const };
|
|
73
|
+
return { kind: "updated" as const, membership: mapRow(this.byId(input.tenantId, input.id) as Row) };
|
|
74
|
+
});
|
|
75
|
+
} catch (error) { const conflict = this.conflict(error); if (conflict) return { kind: conflict }; throw error; }
|
|
76
|
+
}
|
|
77
|
+
private byId(tenantId: string, id: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND id = ?`).get(tenantId, id) as Row | undefined; }
|
|
78
|
+
private byAssistant(tenantId: string, projectId: string, assistantId: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND assistant_id = ?`).get(tenantId, projectId, assistantId) as Row | undefined; }
|
|
79
|
+
private reactivate(existing: Row, input: Omit<ProjectBotMembership, "joinedAt" | "updatedAt">): { kind: "updated" | "reactivated"; membership: ProjectBotMembership } | { kind: Conflict } {
|
|
80
|
+
const persisted = mapRow(existing);
|
|
81
|
+
const wasRemoved = persisted.status === "removed";
|
|
82
|
+
const conflict = this.preflight(input.tenantId, input.projectId, persisted.roomId, input.role, input.mentionName, "active", persisted.id);
|
|
83
|
+
if (conflict) return { kind: conflict };
|
|
84
|
+
try { write(this.db, "UPDATE lt_project_bot_memberships SET role = ?, title = ?, responsibility = ?, mention_name = ?, status = 'active', updated_at = ? WHERE tenant_id = ? AND id = ?", [input.role, input.title, input.responsibility ?? null, input.mentionName, timestamp(existing.updated_at as string), input.tenantId, persisted.id]); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (!isUniqueError(error)) throw error;
|
|
87
|
+
const racedConflict = this.preflight(input.tenantId, input.projectId, persisted.roomId, input.role, input.mentionName, "active", persisted.id);
|
|
88
|
+
if (racedConflict) return { kind: racedConflict };
|
|
89
|
+
throw new Error("Unclassified project bot membership uniqueness conflict");
|
|
90
|
+
}
|
|
91
|
+
return { kind: wasRemoved ? "reactivated" : "updated", membership: mapRow(this.byId(input.tenantId, persisted.id) as Row) };
|
|
92
|
+
}
|
|
93
|
+
private preflight(tenantId: string, projectId: string, roomId: string, role: ProjectBotRole, mentionName: string, status: ProjectBotMembershipStatus, excludeId: string): Conflict | undefined {
|
|
94
|
+
if (status === "removed") return undefined;
|
|
95
|
+
if (role === "coordinator" && this.db.prepare("SELECT 1 FROM lt_project_bot_memberships WHERE tenant_id = ? AND project_id = ? AND id <> ? AND role = 'coordinator' AND status IN ('active', 'paused')").get(tenantId, projectId, excludeId)) return "coordinator_conflict";
|
|
96
|
+
if (this.db.prepare("SELECT 1 FROM lt_project_bot_memberships WHERE tenant_id = ? AND room_id = ? AND mention_name = ? AND id <> ? AND status IN ('active', 'paused')").get(tenantId, roomId, mentionName, excludeId)) return "mention_conflict";
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
private conflict(error: unknown): Conflict | undefined {
|
|
100
|
+
if (!isUniqueError(error) || !(error instanceof Error)) return undefined;
|
|
101
|
+
if (error.message.includes("tenant_id, lt_project_bot_memberships.project_id")) return "coordinator_conflict";
|
|
102
|
+
if (error.message.includes("room_id") && error.message.includes("mention_name")) return "mention_conflict";
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProjectHumanRole, ProjectMembership, ProjectMembershipMutationResult, ProjectMembershipStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, isUniqueError, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; project_id: unknown; user_id: unknown; role: unknown; status: unknown; joined_at: unknown; updated_at: unknown };
|
|
6
|
+
const columns = "id, tenant_id, project_id, user_id, role, status, joined_at, updated_at";
|
|
7
|
+
|
|
8
|
+
export class DuplicateProjectMembershipError extends Error { constructor() { super("Project membership already exists for tenant, project, and user"); this.name = "DuplicateProjectMembershipError"; } }
|
|
9
|
+
export class ProjectMembershipIdConflictError extends Error { constructor() { super("Project membership ID already exists for tenant"); this.name = "ProjectMembershipIdConflictError"; } }
|
|
10
|
+
function role(value: unknown): value is ProjectHumanRole { return value === "owner" || value === "admin" || value === "member" || value === "viewer"; }
|
|
11
|
+
function mapRow(row: Row): ProjectMembership {
|
|
12
|
+
const joinedAt = typeof row.joined_at === "string" ? new Date(row.joined_at) : undefined;
|
|
13
|
+
const updatedAt = typeof row.updated_at === "string" ? new Date(row.updated_at) : undefined;
|
|
14
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.project_id !== "string"
|
|
15
|
+
|| typeof row.user_id !== "string" || !role(row.role) || (row.status !== "active" && row.status !== "removed")
|
|
16
|
+
|| !validDate(joinedAt) || !validDate(updatedAt)) throw new Error("Invalid project membership row");
|
|
17
|
+
return { id: row.id, tenantId: row.tenant_id, projectId: row.project_id, userId: row.user_id,
|
|
18
|
+
role: row.role, status: row.status, joinedAt, updatedAt };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Persists human project memberships in local SQLite. */
|
|
22
|
+
export class LocalProjectMembershipStore implements ProjectMembershipStore {
|
|
23
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
24
|
+
async list(tenantId: string, projectId: string): Promise<ProjectMembership[]> { return (this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? ORDER BY joined_at ASC, id ASC`).all(tenantId, projectId) as Row[]).map(mapRow); }
|
|
25
|
+
async findByUser(tenantId: string, projectId: string, userId: string): Promise<ProjectMembership | null> { const row = this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? AND user_id = ?`).get(tenantId, projectId, userId) as Row | undefined; return row ? mapRow(row) : null; }
|
|
26
|
+
async create(input: Omit<ProjectMembership, "joinedAt" | "updatedAt">): Promise<ProjectMembership> {
|
|
27
|
+
return transaction(this.db, () => {
|
|
28
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectMembershipIdConflictError();
|
|
29
|
+
const now = timestamp();
|
|
30
|
+
try { write(this.db, "INSERT INTO lt_project_memberships (id, tenant_id, project_id, user_id, role, status, joined_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [input.id, input.tenantId, input.projectId, input.userId, input.role, input.status, now, now]); }
|
|
31
|
+
catch (error) { if (isUniqueError(error)) throw this.classifyUnique(input.tenantId, input.id, input.projectId, input.userId); throw error; }
|
|
32
|
+
return mapRow(this.byId(input.tenantId, input.id) as Row);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async createInitialOwner(input: Omit<ProjectMembership, "role" | "status" | "joinedAt" | "updatedAt">): Promise<{ kind: "created" | "existing"; membership: ProjectMembership } | { kind: "already_initialized" }> {
|
|
36
|
+
return transaction(this.db, () => {
|
|
37
|
+
const rows = this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ?`).all(input.tenantId, input.projectId) as Row[];
|
|
38
|
+
if (rows.length > 0) { const existing = rows.map(mapRow).find((item) => item.userId === input.userId && item.role === "owner" && item.status === "active"); return existing ? { kind: "existing", membership: existing } : { kind: "already_initialized" }; }
|
|
39
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectMembershipIdConflictError();
|
|
40
|
+
const now = timestamp();
|
|
41
|
+
try { write(this.db, "INSERT INTO lt_project_memberships (id, tenant_id, project_id, user_id, role, status, joined_at, updated_at) VALUES (?, ?, ?, ?, 'owner', 'active', ?, ?)", [input.id, input.tenantId, input.projectId, input.userId, now, now]); }
|
|
42
|
+
catch (error) { if (isUniqueError(error)) throw this.classifyUnique(input.tenantId, input.id, input.projectId, input.userId); throw error; }
|
|
43
|
+
return { kind: "created", membership: mapRow(this.byId(input.tenantId, input.id) as Row) };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async updateRole(input: { tenantId: string; id: string; role: ProjectHumanRole; expectedUpdatedAt: Date }): Promise<ProjectMembershipMutationResult> { return this.mutate(input.tenantId, input.id, input.expectedUpdatedAt, input.role, false); }
|
|
47
|
+
async remove(input: { tenantId: string; id: string; expectedUpdatedAt: Date }): Promise<ProjectMembershipMutationResult> { return this.mutate(input.tenantId, input.id, input.expectedUpdatedAt, undefined, true); }
|
|
48
|
+
private mutate(tenantId: string, id: string, expected: Date, nextRole: ProjectHumanRole, remove: false): ProjectMembershipMutationResult;
|
|
49
|
+
private mutate(tenantId: string, id: string, expected: Date, nextRole: undefined, remove: true): ProjectMembershipMutationResult;
|
|
50
|
+
private mutate(tenantId: string, id: string, expected: Date, nextRole: ProjectHumanRole | undefined, remove: boolean): ProjectMembershipMutationResult {
|
|
51
|
+
return transaction(this.db, () => {
|
|
52
|
+
const row = this.byId(tenantId, id); if (!row) return { kind: "not_found" };
|
|
53
|
+
const current = mapRow(row); if (!validDate(expected) || current.updatedAt.getTime() !== expected.getTime()) return { kind: "conflict" };
|
|
54
|
+
if (current.role === "owner" && current.status === "active" && (remove || nextRole !== "owner")) {
|
|
55
|
+
const other = this.db.prepare("SELECT 1 FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? AND id <> ? AND role = 'owner' AND status = 'active'").get(tenantId, current.projectId, id);
|
|
56
|
+
if (!other) return { kind: "last_owner" };
|
|
57
|
+
}
|
|
58
|
+
const updatedAt = timestamp(row.updated_at as string);
|
|
59
|
+
const changes = remove
|
|
60
|
+
? write(this.db, "UPDATE lt_project_memberships SET status = 'removed', updated_at = ? WHERE tenant_id = ? AND id = ? AND updated_at = ?", [updatedAt, tenantId, id, row.updated_at as string])
|
|
61
|
+
: write(this.db, "UPDATE lt_project_memberships SET role = ?, updated_at = ? WHERE tenant_id = ? AND id = ? AND updated_at = ?", [nextRole as ProjectHumanRole, updatedAt, tenantId, id, row.updated_at as string]);
|
|
62
|
+
if (changes === 0) return { kind: "conflict" };
|
|
63
|
+
return { kind: remove ? "removed" : "updated", membership: mapRow(this.byId(tenantId, id) as Row) };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
private byId(tenantId: string, id: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_memberships WHERE tenant_id = ? AND id = ?`).get(tenantId, id) as Row | undefined; }
|
|
67
|
+
private classifyUnique(tenantId: string, id: string, projectId: string, userId: string): Error {
|
|
68
|
+
if (this.byId(tenantId, id)) return new ProjectMembershipIdConflictError();
|
|
69
|
+
if (this.db.prepare("SELECT 1 FROM lt_project_memberships WHERE tenant_id = ? AND project_id = ? AND user_id = ?").get(tenantId, projectId, userId)) return new DuplicateProjectMembershipError();
|
|
70
|
+
return new Error("Unclassified project membership uniqueness conflict");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ProjectRoomMention, ProjectRoomMessage, ProjectRoomMessageAuthor, ProjectRoomMessageSource, ProjectRoomMessageStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, isUniqueError, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; room_id: unknown; author: unknown; content: unknown; mentions: unknown; reply_to_message_id: unknown; source: unknown; source_id: unknown; idempotency_key: unknown; created_at: unknown };
|
|
6
|
+
const columns = "id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at";
|
|
7
|
+
export class ProjectRoomMessageIdConflictError extends Error { constructor(tenantId: string, id: string) { super(`Project room message ID '${id}' already exists in tenant '${tenantId}'`); this.name = "ProjectRoomMessageIdConflictError"; } }
|
|
8
|
+
export class DuplicateProjectRoomMessageIdempotencyKeyError extends Error { constructor(tenantId: string, roomId: string, key: string) { super(`Project room message idempotency key '${key}' already exists in tenant '${tenantId}' room '${roomId}'`); this.name = "DuplicateProjectRoomMessageIdempotencyKeyError"; } }
|
|
9
|
+
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
10
|
+
function only(value: Record<string, unknown>, keys: string[]): boolean { return Object.keys(value).every((key) => keys.includes(key)); }
|
|
11
|
+
function parseJson(value: unknown): unknown { if (typeof value !== "string") return undefined; try { return JSON.parse(value) as unknown; } catch { return undefined; } }
|
|
12
|
+
function author(value: unknown): ProjectRoomMessageAuthor | undefined { if (!record(value)) return undefined; if (value.type === "human" && only(value, ["type", "userId"]) && typeof value.userId === "string") return { type: "human", userId: value.userId }; if (value.type === "bot" && only(value, ["type", "membershipId", "assistantId"]) && typeof value.membershipId === "string" && typeof value.assistantId === "string") return { type: "bot", membershipId: value.membershipId, assistantId: value.assistantId }; if (value.type === "system" && only(value, ["type"])) return { type: "system" }; return undefined; }
|
|
13
|
+
function mention(value: unknown): ProjectRoomMention | undefined { if (!record(value)) return undefined; if (value.type === "bot" && only(value, ["type", "membershipId"]) && typeof value.membershipId === "string") return { type: "bot", membershipId: value.membershipId }; if (value.type === "team" && only(value, ["type"])) return { type: "team" }; return undefined; }
|
|
14
|
+
function source(value: unknown): value is ProjectRoomMessageSource { return value === "user" || value === "agent" || value === "task" || value === "routine" || value === "system"; }
|
|
15
|
+
function mapRow(row: Row): ProjectRoomMessage {
|
|
16
|
+
const mappedAuthor = author(parseJson(row.author)); const content = parseJson(row.content); const rawMentions = parseJson(row.mentions);
|
|
17
|
+
const mappedMentions = Array.isArray(rawMentions) ? rawMentions.map(mention) : undefined;
|
|
18
|
+
const createdAt = typeof row.created_at === "string" ? new Date(row.created_at) : undefined;
|
|
19
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string" || typeof row.project_id !== "string" || typeof row.room_id !== "string" || !mappedAuthor
|
|
20
|
+
|| !record(content) || content.type !== "text" || typeof content.text !== "string" || !only(content, ["type", "text"])
|
|
21
|
+
|| !mappedMentions || mappedMentions.some((item) => item === undefined) || (row.reply_to_message_id !== null && typeof row.reply_to_message_id !== "string")
|
|
22
|
+
|| !source(row.source) || (row.source_id !== null && typeof row.source_id !== "string") || (row.idempotency_key !== null && typeof row.idempotency_key !== "string") || !validDate(createdAt)) throw new Error("Invalid project room message row");
|
|
23
|
+
return { id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id, roomId: row.room_id,
|
|
24
|
+
author: mappedAuthor, content: { type: "text", text: content.text }, mentions: mappedMentions as ProjectRoomMention[],
|
|
25
|
+
...(row.reply_to_message_id === null ? {} : { replyToMessageId: row.reply_to_message_id }), source: row.source,
|
|
26
|
+
...(row.source_id === null ? {} : { sourceId: row.source_id }), ...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Persists validated project room messages in local SQLite. */
|
|
30
|
+
export class LocalProjectRoomMessageStore implements ProjectRoomMessageStore {
|
|
31
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
32
|
+
async create(input: Omit<ProjectRoomMessage, "createdAt">): Promise<ProjectRoomMessage> { return this.insert(input, false); }
|
|
33
|
+
async createIdempotent(input: Omit<ProjectRoomMessage, "createdAt"> & { idempotencyKey: string }): Promise<ProjectRoomMessage> { return this.insert(input, true); }
|
|
34
|
+
async list(input: { tenantId: string; roomId: string; before?: { createdAt: Date; id: string }; limit: number }): Promise<ProjectRoomMessage[]> {
|
|
35
|
+
if (input.before && !validDate(input.before.createdAt)) throw new RangeError("Project room message cursor date is invalid");
|
|
36
|
+
const limit = Math.min(100, Math.max(1, Math.trunc(Number.isFinite(input.limit) ? input.limit : 1)));
|
|
37
|
+
const rows = input.before
|
|
38
|
+
? this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, input.before.createdAt.toISOString(), input.before.createdAt.toISOString(), input.before.id, limit)
|
|
39
|
+
: this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(input.tenantId, input.roomId, limit);
|
|
40
|
+
return (rows as Row[]).map(mapRow);
|
|
41
|
+
}
|
|
42
|
+
async findById(tenantId: string, id: string): Promise<ProjectRoomMessage | null> { const row = this.byId(tenantId, id); return row ? mapRow(row) : null; }
|
|
43
|
+
private insert(input: Omit<ProjectRoomMessage, "createdAt">, idempotent: boolean): ProjectRoomMessage {
|
|
44
|
+
return transaction(this.db, () => {
|
|
45
|
+
if (idempotent && input.idempotencyKey) { const canonical = this.byKey(input.tenantId, input.roomId, input.idempotencyKey); if (canonical) return mapRow(canonical); }
|
|
46
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
|
|
47
|
+
try { write(this.db, "INSERT INTO lt_project_room_messages (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content), JSON.stringify(input.mentions), input.replyToMessageId ?? null, input.source, input.sourceId ?? null, input.idempotencyKey ?? null, timestamp()]); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!isUniqueError(error)) throw error;
|
|
50
|
+
if (this.byId(input.tenantId, input.id)) throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
|
|
51
|
+
if (input.idempotencyKey) {
|
|
52
|
+
const canonical = this.byKey(input.tenantId, input.roomId, input.idempotencyKey);
|
|
53
|
+
if (canonical && idempotent) return mapRow(canonical);
|
|
54
|
+
if (canonical) throw new DuplicateProjectRoomMessageIdempotencyKeyError(input.tenantId, input.roomId, input.idempotencyKey);
|
|
55
|
+
}
|
|
56
|
+
throw new Error("Unclassified project room message uniqueness conflict");
|
|
57
|
+
}
|
|
58
|
+
return mapRow(this.byId(input.tenantId, input.id) as Row);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
private byId(tenantId: string, id: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND id = ?`).get(tenantId, id) as Row | undefined; }
|
|
62
|
+
private byKey(tenantId: string, roomId: string, key: string): Row | undefined { return this.db.prepare(`SELECT ${columns} FROM lt_project_room_messages WHERE tenant_id = ? AND room_id = ? AND idempotency_key = ?`).get(tenantId, roomId, key) as Row | undefined; }
|
|
63
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ProjectRoom, ProjectRoomStore } from "@axiom-lattice/protocols";
|
|
2
|
+
import { DatabaseWrapper } from "../database";
|
|
3
|
+
import { ensureProjectRoomSchema, timestamp, transaction, validDate, write } from "./project-room-schema";
|
|
4
|
+
|
|
5
|
+
type Row = { id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; type: unknown; name: unknown; created_at: unknown; updated_at: unknown };
|
|
6
|
+
const columns = "id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at";
|
|
7
|
+
|
|
8
|
+
function mapRow(row: Row): ProjectRoom {
|
|
9
|
+
const createdAt = typeof row.created_at === "string" ? new Date(row.created_at) : undefined;
|
|
10
|
+
const updatedAt = typeof row.updated_at === "string" ? new Date(row.updated_at) : undefined;
|
|
11
|
+
if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string"
|
|
12
|
+
|| typeof row.project_id !== "string" || row.type !== "main" || typeof row.name !== "string"
|
|
13
|
+
|| !validDate(createdAt) || !validDate(updatedAt)) throw new Error("Invalid project room row");
|
|
14
|
+
return { id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id,
|
|
15
|
+
type: "main", name: row.name, createdAt, updatedAt };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Persists canonical project main rooms in local SQLite. */
|
|
19
|
+
export class LocalProjectRoomStore implements ProjectRoomStore {
|
|
20
|
+
constructor(private readonly db: DatabaseWrapper) { ensureProjectRoomSchema(db); }
|
|
21
|
+
async ensureMainRoom(input: { id: string; tenantId: string; workspaceId: string; projectId: string; name: string }): Promise<ProjectRoom> {
|
|
22
|
+
return transaction(this.db, () => {
|
|
23
|
+
const existing = this.getRow(input.tenantId, input.projectId);
|
|
24
|
+
if (existing) return mapRow(existing);
|
|
25
|
+
const now = timestamp();
|
|
26
|
+
write(this.db, "INSERT INTO lt_project_rooms (id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at) VALUES (?, ?, ?, ?, 'main', ?, ?, ?)", [input.id, input.tenantId, input.workspaceId, input.projectId, input.name, now, now]);
|
|
27
|
+
return mapRow(this.getRow(input.tenantId, input.projectId) as Row);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async getMainRoom(tenantId: string, projectId: string): Promise<ProjectRoom | null> {
|
|
31
|
+
const row = this.getRow(tenantId, projectId);
|
|
32
|
+
return row ? mapRow(row) : null;
|
|
33
|
+
}
|
|
34
|
+
private getRow(tenantId: string, projectId: string): Row | undefined {
|
|
35
|
+
return this.db.prepare(`SELECT ${columns} FROM lt_project_rooms WHERE tenant_id = ? AND project_id = ? AND type = 'main'`).get(tenantId, projectId) as Row | undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -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,11 +9,18 @@ import type {
|
|
|
9
9
|
CreateWorkItemRequest,
|
|
10
10
|
CreateWorkItemIfAbsentRequest,
|
|
11
11
|
TaskWorkItemListFilter,
|
|
12
|
+
ProjectLifecycleEventQuery,
|
|
13
|
+
TaskMutationSnapshot,
|
|
12
14
|
} from "@axiom-lattice/protocols";
|
|
13
15
|
import { MAX_PENDING_EXECUTION_RESULTS_LIMIT } from "@axiom-lattice/protocols";
|
|
14
16
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
15
17
|
import { v4 as uuid } from "uuid";
|
|
16
18
|
|
|
19
|
+
const PROJECT_LIFECYCLE_ACTIONS: ReadonlySet<string> = new Set([
|
|
20
|
+
"in_progress", "interrupted", "failed", "completed", "cancelled", "reassigned",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
|
|
17
24
|
const DDL = `
|
|
18
25
|
CREATE TABLE IF NOT EXISTS lt_task_work_items (
|
|
19
26
|
id TEXT NOT NULL,
|
|
@@ -48,6 +55,18 @@ interface WorkItemRow {
|
|
|
48
55
|
created_at: string;
|
|
49
56
|
}
|
|
50
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
|
+
|
|
51
70
|
function mapRowToWorkItem(row: WorkItemRow): TaskWorkItem {
|
|
52
71
|
return {
|
|
53
72
|
id: row.id,
|
|
@@ -57,7 +76,7 @@ function mapRowToWorkItem(row: WorkItemRow): TaskWorkItem {
|
|
|
57
76
|
actor: row.actor,
|
|
58
77
|
threadId: row.thread_id ?? undefined,
|
|
59
78
|
summary: row.summary ?? undefined,
|
|
60
|
-
detail:
|
|
79
|
+
detail: parseDetail(row.detail),
|
|
61
80
|
attempt: row.attempt ?? undefined,
|
|
62
81
|
workspaceId: row.workspace_id ?? undefined,
|
|
63
82
|
projectId: row.project_id ?? undefined,
|
|
@@ -66,6 +85,33 @@ function mapRowToWorkItem(row: WorkItemRow): TaskWorkItem {
|
|
|
66
85
|
};
|
|
67
86
|
}
|
|
68
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
|
+
|
|
69
115
|
export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
70
116
|
private db: DatabaseWrapper;
|
|
71
117
|
|
|
@@ -82,6 +128,9 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
82
128
|
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_task_work_items_reconciled_result
|
|
83
129
|
ON lt_task_work_items (tenant_id, task_id, json_extract(detail, '$.executionResultId'))
|
|
84
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;`);
|
|
85
134
|
}
|
|
86
135
|
|
|
87
136
|
/** Add a column if it does not exist (SQLite version compatible). */
|
|
@@ -115,6 +164,31 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
115
164
|
return mapRowToWorkItem(row!);
|
|
116
165
|
}
|
|
117
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
|
+
|
|
118
192
|
/** Find an event by its tenant- and task-scoped key without pagination. */
|
|
119
193
|
async findByEventKey(tenantId: string, taskId: string, eventKey: string): Promise<TaskWorkItem | null> {
|
|
120
194
|
const row = this.db.prepare(
|
|
@@ -199,4 +273,20 @@ export class LocalTaskWorkItemStore implements TaskWorkItemStore {
|
|
|
199
273
|
).all(params.tenantId, params.taskId, params.limit) as unknown as WorkItemRow[];
|
|
200
274
|
return rows.map(mapRowToWorkItem);
|
|
201
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
|
+
}
|
|
202
292
|
}
|