@axiom-lattice/pg-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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.d.mts +195 -15
  4. package/dist/index.d.ts +195 -15
  5. package/dist/index.js +1309 -199
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1295 -191
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/ChannelBindingStore.test.ts +122 -0
  11. package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
  12. package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
  13. package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
  14. package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
  15. package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
  16. package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
  17. package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
  18. package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
  19. package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +74 -0
  20. package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
  21. package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +96 -0
  22. package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
  23. package/src/__tests__/ThreadMessageQueueStore.test.ts +113 -0
  24. package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
  25. package/src/__tests__/task-files.test.ts +4 -3
  26. package/src/createPgStoreConfig.ts +14 -0
  27. package/src/index.ts +7 -0
  28. package/src/migrations/add_trusted_run_context_column.ts +18 -0
  29. package/src/migrations/migration.ts +2 -1
  30. package/src/migrations/project_room_migration.ts +128 -0
  31. package/src/migrations/task_migration.ts +15 -0
  32. package/src/migrations/task_work_items_migration.ts +29 -1
  33. package/src/stores/ChannelBindingStore.ts +99 -59
  34. package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
  35. package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
  36. package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
  37. package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
  38. package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
  39. package/src/stores/PostgreSQLTaskStore.ts +89 -3
  40. package/src/stores/PostgreSQLTaskWorkItemStore.ts +159 -8
  41. package/src/stores/ThreadMessageQueueStore.ts +38 -9
@@ -0,0 +1,177 @@
1
+ import type {
2
+ ProjectRoomMention,
3
+ ProjectRoomMessage,
4
+ ProjectRoomMessageAuthor,
5
+ ProjectRoomMessageSource,
6
+ ProjectRoomMessageStore,
7
+ } from "@axiom-lattice/protocols";
8
+ import type { Pool } from "pg";
9
+
10
+ type MessageRow = {
11
+ id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown; room_id: unknown;
12
+ author: unknown; content: unknown; mentions: unknown; reply_to_message_id: unknown; source: unknown;
13
+ source_id: unknown; idempotency_key: unknown; created_at: unknown;
14
+ };
15
+ type PostgreSQLError = { code?: unknown; constraint?: unknown };
16
+
17
+ 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";
18
+
19
+ /** Indicates that a tenant-scoped room message ID already exists. */
20
+ export class ProjectRoomMessageIdConflictError extends Error {
21
+ /** Creates an accurate tenant-scoped message ID collision error. */
22
+ constructor(tenantId: string, id: string) {
23
+ super(`Project room message ID '${id}' already exists in tenant '${tenantId}'`);
24
+ this.name = "ProjectRoomMessageIdConflictError";
25
+ }
26
+ }
27
+
28
+ /** Indicates that ordinary creation reused a room-scoped idempotency key. */
29
+ export class DuplicateProjectRoomMessageIdempotencyKeyError extends Error {
30
+ /** Creates an accurate tenant and room-scoped idempotency collision error. */
31
+ constructor(tenantId: string, roomId: string, idempotencyKey: string) {
32
+ super(`Project room message idempotency key '${idempotencyKey}' already exists in tenant '${tenantId}' room '${roomId}'`);
33
+ this.name = "DuplicateProjectRoomMessageIdempotencyKeyError";
34
+ }
35
+ }
36
+
37
+ function isRecord(value: unknown): value is Record<string, unknown> {
38
+ return typeof value === "object" && value !== null && !Array.isArray(value);
39
+ }
40
+ function hasOnlyKeys(value: Record<string, unknown>, keys: string[]): boolean {
41
+ return Object.keys(value).every((key) => keys.includes(key));
42
+ }
43
+ function mapAuthor(value: unknown): ProjectRoomMessageAuthor | undefined {
44
+ if (!isRecord(value) || typeof value.type !== "string") return undefined;
45
+ if (value.type === "human" && hasOnlyKeys(value, ["type", "userId"]) && typeof value.userId === "string") return { type: "human", userId: value.userId };
46
+ if (value.type === "bot" && hasOnlyKeys(value, ["type", "membershipId", "assistantId"])
47
+ && typeof value.membershipId === "string" && typeof value.assistantId === "string") {
48
+ return { type: "bot", membershipId: value.membershipId, assistantId: value.assistantId };
49
+ }
50
+ if (value.type === "system" && hasOnlyKeys(value, ["type"])) return { type: "system" };
51
+ return undefined;
52
+ }
53
+ function mapMention(value: unknown): ProjectRoomMention | undefined {
54
+ if (!isRecord(value) || typeof value.type !== "string") return undefined;
55
+ if (value.type === "bot" && hasOnlyKeys(value, ["type", "membershipId"]) && typeof value.membershipId === "string") {
56
+ return { type: "bot", membershipId: value.membershipId };
57
+ }
58
+ if (value.type === "team" && hasOnlyKeys(value, ["type"])) return { type: "team" };
59
+ return undefined;
60
+ }
61
+ function isSource(value: unknown): value is ProjectRoomMessageSource {
62
+ return value === "user" || value === "agent" || value === "task" || value === "routine" || value === "system";
63
+ }
64
+ function mapRow(row: MessageRow): ProjectRoomMessage {
65
+ const author = mapAuthor(row.author);
66
+ const content = isRecord(row.content) && row.content.type === "text" && typeof row.content.text === "string"
67
+ && hasOnlyKeys(row.content, ["type", "text"]) ? { type: "text" as const, text: row.content.text } : undefined;
68
+ const mentions = Array.isArray(row.mentions) ? row.mentions.map(mapMention) : undefined;
69
+ if (typeof row.id !== "string" || typeof row.tenant_id !== "string" || typeof row.workspace_id !== "string"
70
+ || typeof row.project_id !== "string" || typeof row.room_id !== "string" || !author || !content || !mentions
71
+ || mentions.some((mention) => mention === undefined)
72
+ || (row.reply_to_message_id !== null && typeof row.reply_to_message_id !== "string") || !isSource(row.source)
73
+ || (row.source_id !== null && typeof row.source_id !== "string")
74
+ || (row.idempotency_key !== null && typeof row.idempotency_key !== "string")
75
+ || !(row.created_at instanceof Date) || Number.isNaN(row.created_at.getTime())) {
76
+ throw new Error("Invalid project room message row");
77
+ }
78
+ return {
79
+ id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id, projectId: row.project_id,
80
+ roomId: row.room_id, author, content, mentions: mentions as ProjectRoomMention[],
81
+ ...(row.reply_to_message_id === null ? {} : { replyToMessageId: row.reply_to_message_id }),
82
+ source: row.source, ...(row.source_id === null ? {} : { sourceId: row.source_id }),
83
+ ...(row.idempotency_key === null ? {} : { idempotencyKey: row.idempotency_key }), createdAt: row.created_at,
84
+ };
85
+ }
86
+ function isUniqueViolation(error: unknown): error is PostgreSQLError {
87
+ return typeof error === "object" && error !== null && "code" in error
88
+ && (error as PostgreSQLError).code === "23505";
89
+ }
90
+
91
+ /** Persists tenant-isolated project room messages in PostgreSQL. */
92
+ export class PostgreSQLProjectRoomMessageStore implements ProjectRoomMessageStore {
93
+ /** Creates a store using an externally managed shared pool. */
94
+ constructor(options: { pool: Pool }) { this.pool = options.pool; }
95
+ private readonly pool: Pool;
96
+
97
+ /** Creates a room message and maps known uniqueness failures to typed errors. */
98
+ async create(input: Omit<ProjectRoomMessage, "createdAt">): Promise<ProjectRoomMessage> {
99
+ try {
100
+ const result = await this.pool.query<MessageRow>(
101
+ `INSERT INTO lattice_project_room_messages
102
+ (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at)
103
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, date_trunc('milliseconds', clock_timestamp()))
104
+ RETURNING ${columns}`,
105
+ this.parameters(input),
106
+ );
107
+ return mapRow(result.rows[0]);
108
+ } catch (error) {
109
+ if (isUniqueViolation(error) && error.constraint === "lattice_project_room_messages_pkey") {
110
+ throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
111
+ }
112
+ if (isUniqueViolation(error) && error.constraint === "uq_lattice_project_room_messages_idempotency") {
113
+ throw new DuplicateProjectRoomMessageIdempotencyKeyError(input.tenantId, input.roomId, input.idempotencyKey ?? "");
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ /** Atomically creates or returns the canonical message for a room-scoped idempotency key. */
120
+ async createIdempotent(
121
+ input: Omit<ProjectRoomMessage, "createdAt"> & { idempotencyKey: string },
122
+ ): Promise<ProjectRoomMessage> {
123
+ try {
124
+ const result = await this.pool.query<MessageRow>(
125
+ `INSERT INTO lattice_project_room_messages
126
+ (id, tenant_id, workspace_id, project_id, room_id, author, content, mentions, reply_to_message_id, source, source_id, idempotency_key, created_at)
127
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, date_trunc('milliseconds', clock_timestamp()))
128
+ ON CONFLICT (tenant_id, room_id, idempotency_key) WHERE idempotency_key IS NOT NULL
129
+ DO UPDATE SET idempotency_key = lattice_project_room_messages.idempotency_key
130
+ RETURNING ${columns}`,
131
+ this.parameters(input),
132
+ );
133
+ return mapRow(result.rows[0]);
134
+ } catch (error) {
135
+ if (isUniqueViolation(error) && error.constraint === "lattice_project_room_messages_pkey") {
136
+ throw new ProjectRoomMessageIdConflictError(input.tenantId, input.id);
137
+ }
138
+ throw error;
139
+ }
140
+ }
141
+
142
+ /** Lists messages newest first using an exclusive stable cursor and a clamped limit. */
143
+ async list(input: {
144
+ tenantId: string; roomId: string; before?: { createdAt: Date; id: string }; limit: number;
145
+ }): Promise<ProjectRoomMessage[]> {
146
+ if (input.before && (!(input.before.createdAt instanceof Date) || Number.isNaN(input.before.createdAt.getTime()))) {
147
+ throw new RangeError("Project room message cursor date is invalid");
148
+ }
149
+ const limit = Math.min(100, Math.max(1, Math.trunc(Number.isFinite(input.limit) ? input.limit : 1)));
150
+ const result = input.before
151
+ ? await this.pool.query<MessageRow>(
152
+ `SELECT ${columns} FROM lattice_project_room_messages
153
+ WHERE tenant_id = $1 AND room_id = $2 AND (created_at < $3 OR (created_at = $3 AND id < $4))
154
+ ORDER BY created_at DESC, id DESC LIMIT $5`,
155
+ [input.tenantId, input.roomId, input.before.createdAt, input.before.id, limit],
156
+ )
157
+ : await this.pool.query<MessageRow>(
158
+ `SELECT ${columns} FROM lattice_project_room_messages
159
+ WHERE tenant_id = $1 AND room_id = $2 ORDER BY created_at DESC, id DESC LIMIT $3`,
160
+ [input.tenantId, input.roomId, limit],
161
+ );
162
+ return result.rows.map(mapRow);
163
+ }
164
+
165
+ /** Finds a message by tenant-scoped ID. */
166
+ async findById(tenantId: string, id: string): Promise<ProjectRoomMessage | null> {
167
+ const result = await this.pool.query<MessageRow>(
168
+ `SELECT ${columns} FROM lattice_project_room_messages WHERE tenant_id = $1 AND id = $2`, [tenantId, id],
169
+ );
170
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
171
+ }
172
+
173
+ private parameters(input: Omit<ProjectRoomMessage, "createdAt">): unknown[] {
174
+ return [input.id, input.tenantId, input.workspaceId, input.projectId, input.roomId, JSON.stringify(input.author), JSON.stringify(input.content),
175
+ JSON.stringify(input.mentions), input.replyToMessageId ?? null, input.source, input.sourceId ?? null, input.idempotencyKey ?? null];
176
+ }
177
+ }
@@ -0,0 +1,57 @@
1
+ import type { ProjectRoom, ProjectRoomStore } from "@axiom-lattice/protocols";
2
+ import type { Pool } from "pg";
3
+
4
+ type ProjectRoomRow = {
5
+ id: unknown; tenant_id: unknown; workspace_id: unknown; project_id: unknown;
6
+ type: unknown; name: unknown; created_at: unknown; updated_at: unknown;
7
+ };
8
+
9
+ function isValidDate(value: unknown): value is Date {
10
+ return value instanceof Date && !Number.isNaN(value.getTime());
11
+ }
12
+
13
+ function mapRow(row: ProjectRoomRow): ProjectRoom {
14
+ if (typeof row.id !== "string" || typeof row.tenant_id !== "string"
15
+ || typeof row.workspace_id !== "string" || typeof row.project_id !== "string"
16
+ || row.type !== "main" || typeof row.name !== "string"
17
+ || !isValidDate(row.created_at) || !isValidDate(row.updated_at)) {
18
+ throw new Error("Invalid project room row");
19
+ }
20
+ return {
21
+ id: row.id, tenantId: row.tenant_id, workspaceId: row.workspace_id,
22
+ projectId: row.project_id, type: "main", name: row.name,
23
+ createdAt: row.created_at, updatedAt: row.updated_at,
24
+ };
25
+ }
26
+
27
+ /** Persists tenant-isolated canonical project main rooms in PostgreSQL. */
28
+ export class PostgreSQLProjectRoomStore implements ProjectRoomStore {
29
+ /** Creates a store using an externally managed pool; the pool is not migrated or closed. */
30
+ constructor(options: { pool: Pool }) { this.pool = options.pool; }
31
+ private readonly pool: Pool;
32
+
33
+ /** Creates or returns the single persisted main room for a project. */
34
+ async ensureMainRoom(input: {
35
+ id: string; tenantId: string; workspaceId: string; projectId: string; name: string;
36
+ }): Promise<ProjectRoom> {
37
+ const result = await this.pool.query<ProjectRoomRow>(
38
+ `INSERT INTO lattice_project_rooms
39
+ (id, tenant_id, workspace_id, project_id, type, name)
40
+ VALUES ($1, $2, $3, $4, $5, $6)
41
+ ON CONFLICT (tenant_id, project_id, type)
42
+ DO UPDATE SET updated_at = lattice_project_rooms.updated_at
43
+ RETURNING id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at`,
44
+ [input.id, input.tenantId, input.workspaceId, input.projectId, "main", input.name],
45
+ );
46
+ return mapRow(result.rows[0]);
47
+ }
48
+
49
+ /** Finds a tenant-scoped project's main room, or returns null. */
50
+ async getMainRoom(tenantId: string, projectId: string): Promise<ProjectRoom | null> {
51
+ const result = await this.pool.query<ProjectRoomRow>(
52
+ "SELECT id, tenant_id, workspace_id, project_id, type, name, created_at, updated_at FROM lattice_project_rooms WHERE tenant_id = $1 AND project_id = $2 AND type = 'main'",
53
+ [tenantId, projectId],
54
+ );
55
+ return result.rows[0] ? mapRow(result.rows[0]) : null;
56
+ }
57
+ }
@@ -11,11 +11,18 @@ import type {
11
11
  UpdateTaskRequest,
12
12
  TaskListFilter,
13
13
  TaskFileRef,
14
+ TaskDependentListQuery,
15
+ TaskMutationSnapshot,
14
16
  } from "@axiom-lattice/protocols";
15
17
  import { MigrationManager } from "../migrations/migration";
16
- import { createTasksTable, addTaskFieldsMigration, addTaskProjectFieldsMigration, addFilesToTasks } from "../migrations/task_migration";
18
+ import { createTasksTable, addTaskFieldsMigration, addTaskProjectFieldsMigration, addFilesToTasks, addTaskDependenciesGinIndex } from "../migrations/task_migration";
17
19
  import { v4 as uuidv4 } from "uuid";
18
20
 
21
+ const TASK_STATUSES: ReadonlySet<string> = new Set([
22
+ "pending", "in_progress", "review", "failed", "interrupted", "completed", "cancelled",
23
+ ]);
24
+
25
+
19
26
  interface TaskRow {
20
27
  id: string;
21
28
  tenant_id: string;
@@ -131,6 +138,11 @@ function mapRowToTask(row: TaskRow): TaskItem {
131
138
  };
132
139
  }
133
140
 
141
+ function assertPage(limit: number, offset = 0): void {
142
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100
143
+ || !Number.isSafeInteger(offset) || offset < 0) throw new RangeError("Invalid task page");
144
+ }
145
+
134
146
  export interface PostgreSQLTaskStoreOptions {
135
147
  pool?: Pool;
136
148
  poolConfig?: string | PoolConfig;
@@ -166,6 +178,7 @@ export class PostgreSQLTaskStore implements TaskStore {
166
178
  this.migrationManager.register(addTaskFieldsMigration);
167
179
  this.migrationManager.register(addTaskProjectFieldsMigration);
168
180
  this.migrationManager.register(addFilesToTasks);
181
+ this.migrationManager.register(addTaskDependenciesGinIndex);
169
182
 
170
183
  if (options.autoMigrate !== false) {
171
184
  this.initialize().catch((error) => {
@@ -274,7 +287,9 @@ export class PostgreSQLTaskStore implements TaskStore {
274
287
  conditions.push(`workspace_id = $${paramIndex++}`);
275
288
  params.push(filter.workspaceId);
276
289
  }
277
- if (filter.projectId) {
290
+ if (filter.projectId === null) {
291
+ conditions.push("(project_id IS NULL OR project_id = '' OR project_id = 'default')");
292
+ } else if (filter.projectId !== undefined) {
278
293
  conditions.push(`project_id = $${paramIndex++}`);
279
294
  params.push(filter.projectId);
280
295
  }
@@ -301,12 +316,30 @@ export class PostgreSQLTaskStore implements TaskStore {
301
316
  const offset = filter.offset || 0;
302
317
 
303
318
  const result = await this.pool.query<TaskRow>(
304
- `SELECT * FROM lattice_tasks WHERE ${where} ORDER BY created_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
319
+ `SELECT * FROM lattice_tasks WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
305
320
  [...params, limit, offset],
306
321
  );
307
322
  return result.rows.map((r) => mapRowToTask(r));
308
323
  }
309
324
 
325
+ /** Lists exact project tasks containing a JSON string dependency. */
326
+ async listDependents(query: TaskDependentListQuery): Promise<TaskItem[]> {
327
+ assertPage(query.limit, query.offset);
328
+ if (query.statuses.length === 0 || query.statuses.some((status) => !TASK_STATUSES.has(status))) {
329
+ throw new RangeError("Invalid task statuses");
330
+ }
331
+ await this.ensureInitialized();
332
+ const result = await this.pool.query<TaskRow>(
333
+ `SELECT * FROM lattice_tasks
334
+ WHERE tenant_id=$1 AND workspace_id=$2 AND project_id=$3
335
+ AND dependencies @> $4::jsonb AND status = ANY($5::text[])
336
+ ORDER BY created_at DESC, id DESC LIMIT $6 OFFSET $7`,
337
+ [query.tenantId, query.workspaceId, query.projectId, JSON.stringify([query.dependencyTaskId]),
338
+ query.statuses, query.limit, query.offset],
339
+ );
340
+ return result.rows.map(mapRowToTask);
341
+ }
342
+
310
343
  async update(
311
344
  tenantId: string,
312
345
  id: string,
@@ -558,6 +591,45 @@ export class PostgreSQLTaskStore implements TaskStore {
558
591
  return result.rows[0] ? mapRowToTask(result.rows[0]) : null;
559
592
  }
560
593
 
594
+ /** Atomically update only while status, timestamp, owner, and Project scope match. */
595
+ async updateIfSnapshot(
596
+ tenantId: string, id: string, updates: UpdateTaskRequest, snapshot: TaskMutationSnapshot,
597
+ ): Promise<TaskItem | null> {
598
+ await this.ensureInitialized();
599
+ const setClauses: string[] = [];
600
+ const params: unknown[] = [];
601
+ let index = 1;
602
+ const fields: Array<[keyof UpdateTaskRequest, string, boolean?]> = [
603
+ ["title", "title"], ["description", "description"], ["status", "status"], ["priority", "priority"],
604
+ ["dueDate", "due_date"], ["metadata", "metadata", true], ["files", "files", true],
605
+ ["parentId", "parent_id"], ["sourceId", "source_id"], ["context", "context", true],
606
+ ["ownerType", "owner_type"], ["ownerId", "owner_id"], ["requireReview", "require_review"],
607
+ ["dependencies", "dependencies", true], ["result", "result"], ["failureReason", "failure_reason"],
608
+ ["workspaceId", "workspace_id"], ["projectId", "project_id"],
609
+ ];
610
+ for (const [field, column, json] of fields) {
611
+ const value = updates[field];
612
+ if (value === undefined) continue;
613
+ setClauses.push(`${column} = $${index++}`);
614
+ params.push(json && value !== null ? JSON.stringify(value) : value);
615
+ }
616
+ if (setClauses.length === 0) return this.getById(tenantId, id);
617
+ setClauses.push(`updated_at = ${nextUpdatedAtSql()}`);
618
+ const predicates = [tenantId, id, snapshot.status, new Date(snapshot.updatedAt).toISOString(), snapshot.ownerType,
619
+ snapshot.ownerId, snapshot.workspaceId, snapshot.projectId];
620
+ const placeholders = predicates.map(() => `$${index++}`);
621
+ params.push(...predicates);
622
+ const result = await this.pool.query<TaskRow>(
623
+ `UPDATE lattice_tasks SET ${setClauses.join(", ")}
624
+ WHERE tenant_id = ${placeholders[0]} AND id = ${placeholders[1]} AND status = ${placeholders[2]}
625
+ AND ${canonicalTimestampSnapshotSql("updated_at", placeholders[3])}
626
+ AND owner_type = ${placeholders[4]} AND owner_id = ${placeholders[5]}
627
+ AND workspace_id IS NOT DISTINCT FROM ${placeholders[6]}
628
+ AND project_id IS NOT DISTINCT FROM ${placeholders[7]} RETURNING *`, params,
629
+ );
630
+ return result.rows[0] ? mapRowToTask(result.rows[0]) : null;
631
+ }
632
+
561
633
  /** Atomically update a child only when both child and parent snapshots match. */
562
634
  async updateIfStatusUpdatedAtAndParentUpdatedAt(
563
635
  tenantId: string,
@@ -631,6 +703,20 @@ export class PostgreSQLTaskStore implements TaskStore {
631
703
  return (result.rowCount ?? 0) > 0;
632
704
  }
633
705
 
706
+ /** Atomically delete only while status, timestamp, owner, and Project scope match. */
707
+ async deleteIfSnapshot(tenantId: string, id: string, snapshot: TaskMutationSnapshot): Promise<boolean> {
708
+ await this.ensureInitialized();
709
+ const result = await this.pool.query(
710
+ `DELETE FROM lattice_tasks WHERE tenant_id = $1 AND id = $2 AND status = $3
711
+ AND ${canonicalTimestampSnapshotSql("updated_at", "$4")}
712
+ AND owner_type = $5 AND owner_id = $6
713
+ AND workspace_id IS NOT DISTINCT FROM $7 AND project_id IS NOT DISTINCT FROM $8`,
714
+ [tenantId, id, snapshot.status, new Date(snapshot.updatedAt).toISOString(), snapshot.ownerType,
715
+ snapshot.ownerId, snapshot.workspaceId, snapshot.projectId],
716
+ );
717
+ return (result.rowCount ?? 0) > 0;
718
+ }
719
+
634
720
  async dispose(): Promise<void> {
635
721
  if (this.ownsPool && this.pool) {
636
722
  await this.pool.end();
@@ -1,17 +1,97 @@
1
- import type { Pool } from "pg";
2
- import type { TaskWorkItemStore, TaskWorkItem, CreateWorkItemRequest, CreateWorkItemIfAbsentRequest, TaskWorkItemListFilter } from "@axiom-lattice/protocols";
1
+ import { Pool } from "pg";
2
+ import type { PoolConfig } from "pg";
3
+ import type { TaskWorkItemStore, TaskWorkItem, CreateWorkItemRequest, CreateWorkItemIfAbsentRequest, TaskWorkItemListFilter, ProjectLifecycleEventQuery, TaskMutationSnapshot } from "@axiom-lattice/protocols";
3
4
  import { MAX_PENDING_EXECUTION_RESULTS_LIMIT } from "@axiom-lattice/protocols";
4
5
  import { v4 } from "uuid";
6
+ import { MigrationManager } from "../migrations/migration";
7
+ import { taskWorkItemMigrations } from "../migrations/task_work_items_migration";
5
8
 
9
+ const PROJECT_LIFECYCLE_ACTIONS: ReadonlySet<string> = new Set([
10
+ "in_progress", "interrupted", "failed", "completed", "cancelled", "reassigned",
11
+ ]);
12
+
13
+ /** PostgreSQL TaskWorkItem store connection and migration options. */
14
+ export interface PostgreSQLTaskWorkItemStoreOptions {
15
+ /** External caller-owned pool; central migrations must already be applied. */
16
+ pool?: Pool;
17
+ /** Connection configuration for a standalone, store-owned pool. */
18
+ poolConfig?: string | PoolConfig;
19
+ /** Whether a standalone store eagerly applies migrations. Defaults to true. */
20
+ autoMigrate?: boolean;
21
+ }
22
+
23
+ function isRecord(value: unknown): value is Record<string, unknown> {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+
27
+ function isPool(value: Pool | PostgreSQLTaskWorkItemStoreOptions): value is Pool {
28
+ return typeof (value as { query?: unknown }).query === "function";
29
+ }
30
+
31
+ /** PostgreSQL TaskWorkItem persistence with optional standalone migrations. */
6
32
  export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
7
- constructor(private pool: Pool) {}
33
+ private pool: Pool;
34
+ private migrationManager!: MigrationManager;
35
+ private initialized = false;
36
+ private ownsPool = false;
37
+ private initPromise: Promise<void> | null = null;
38
+
39
+ constructor(pool: Pool);
40
+ constructor(options: PostgreSQLTaskWorkItemStoreOptions);
41
+ constructor(poolOrOptions: Pool | PostgreSQLTaskWorkItemStoreOptions) {
42
+ if (isPool(poolOrOptions)) {
43
+ this.pool = poolOrOptions;
44
+ this.initialized = true;
45
+ return;
46
+ }
47
+ const options = poolOrOptions as PostgreSQLTaskWorkItemStoreOptions;
48
+ if (options.pool) {
49
+ this.pool = options.pool;
50
+ this.initialized = true;
51
+ return;
52
+ }
53
+ this.pool = typeof options.poolConfig === "string"
54
+ ? new Pool({ connectionString: options.poolConfig })
55
+ : options.poolConfig
56
+ ? new Pool(options.poolConfig)
57
+ : (() => { throw new Error("Either pool or poolConfig must be provided"); })();
58
+ this.ownsPool = true;
59
+ this.migrationManager = new MigrationManager(this.pool);
60
+ for (const migration of taskWorkItemMigrations) this.migrationManager.register(migration);
61
+ if (options.autoMigrate !== false) this.startInitialization();
62
+ }
63
+
64
+ /** Applies the complete standalone TaskWorkItem migration chain once. */
65
+ async initialize(): Promise<void> {
66
+ if (this.initialized) return;
67
+ if (this.initPromise) return this.initPromise;
68
+ return this.startInitialization();
69
+ }
70
+
71
+ /** Closes the pool only when this store created it from connection configuration. */
72
+ async dispose(): Promise<void> {
73
+ if (this.ownsPool) await this.pool.end();
74
+ }
75
+
76
+ private startInitialization(): Promise<void> {
77
+ this.initPromise = this.migrationManager.migrate().then(() => {
78
+ this.initialized = true;
79
+ });
80
+ void this.initPromise.catch(() => undefined);
81
+ return this.initPromise;
82
+ }
83
+
84
+ private async ensureInitialized(): Promise<void> {
85
+ if (!this.initialized) await this.initialize();
86
+ }
8
87
 
9
88
  async create(params: CreateWorkItemRequest): Promise<TaskWorkItem> {
89
+ await this.ensureInitialized();
10
90
  const id = v4();
11
91
  const result = await this.pool.query(
12
92
  `INSERT INTO lattice_task_work_items
13
- (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id)
14
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
93
+ (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, created_at)
94
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, date_trunc('milliseconds', clock_timestamp()))
15
95
  RETURNING *`,
16
96
  [
17
97
  id, params.tenantId, params.taskId, params.action, params.actor,
@@ -25,8 +105,36 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
25
105
  return this.rowToItem(result.rows[0]);
26
106
  }
27
107
 
108
+ /** Atomically inserts a work item by selecting one exact task snapshot. */
109
+ async createIfTaskSnapshot(
110
+ params: CreateWorkItemRequest,
111
+ snapshot: TaskMutationSnapshot,
112
+ ): Promise<TaskWorkItem | null> {
113
+ await this.ensureInitialized();
114
+ const result = await this.pool.query(
115
+ `INSERT INTO lattice_task_work_items
116
+ (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, created_at)
117
+ SELECT $1, task.tenant_id, task.id, $2, $3, $4, $5, $6, $7, task.workspace_id, task.project_id,
118
+ date_trunc('milliseconds', clock_timestamp())
119
+ FROM lattice_tasks AS task
120
+ WHERE task.tenant_id = $8 AND task.id = $9 AND task.status = $10
121
+ AND date_trunc('milliseconds', task.updated_at::timestamptz) = $11::timestamptz
122
+ AND task.owner_type = $12 AND task.owner_id = $13
123
+ AND task.workspace_id IS NOT DISTINCT FROM $14 AND task.project_id IS NOT DISTINCT FROM $15
124
+ AND $14 IS NOT DISTINCT FROM $16 AND $15 IS NOT DISTINCT FROM $17
125
+ RETURNING *`,
126
+ [v4(), params.action, params.actor, params.threadId || null, params.summary || null,
127
+ params.detail ? JSON.stringify(params.detail) : null, params.attempt ?? null,
128
+ params.tenantId, params.taskId, snapshot.status, new Date(snapshot.updatedAt).toISOString(),
129
+ snapshot.ownerType, snapshot.ownerId, snapshot.workspaceId, snapshot.projectId,
130
+ params.workspaceId, params.projectId],
131
+ );
132
+ return result.rows[0] ? this.rowToItem(result.rows[0]) : null;
133
+ }
134
+
28
135
  /** Find an event by its tenant- and task-scoped key without pagination. */
29
136
  async findByEventKey(tenantId: string, taskId: string, eventKey: string): Promise<TaskWorkItem | null> {
137
+ await this.ensureInitialized();
30
138
  const result = await this.pool.query(
31
139
  `SELECT * FROM lattice_task_work_items
32
140
  WHERE tenant_id = $1 AND task_id = $2 AND event_key = $3`,
@@ -37,10 +145,11 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
37
145
 
38
146
  /** Atomically return an existing event or create it once. */
39
147
  async createIfAbsentByEventKey(params: CreateWorkItemIfAbsentRequest): Promise<TaskWorkItem> {
148
+ await this.ensureInitialized();
40
149
  const result = await this.pool.query(
41
150
  `INSERT INTO lattice_task_work_items
42
- (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, event_key)
43
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
151
+ (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, event_key, created_at)
152
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, date_trunc('milliseconds', clock_timestamp()))
44
153
  ON CONFLICT (tenant_id, task_id, event_key) WHERE event_key IS NOT NULL
45
154
  DO UPDATE SET event_key = EXCLUDED.event_key
46
155
  RETURNING *`,
@@ -56,6 +165,7 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
56
165
  }
57
166
 
58
167
  async list(filter: TaskWorkItemListFilter): Promise<TaskWorkItem[]> {
168
+ await this.ensureInitialized();
59
169
  let query = `SELECT * FROM lattice_task_work_items WHERE tenant_id = $1 AND task_id = $2`;
60
170
  const params: unknown[] = [filter.tenantId, filter.taskId];
61
171
 
@@ -126,6 +236,47 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
126
236
  return result.rows.map((row: Record<string, unknown>) => this.rowToItem(row));
127
237
  }
128
238
 
239
+ /** Lists canonical project lifecycle events with an exclusive cursor. */
240
+ async listProjectLifecycleEvents(query: ProjectLifecycleEventQuery): Promise<TaskWorkItem[]> {
241
+ let cursorMilliseconds: number | null = null;
242
+ if (!Number.isSafeInteger(query.limit) || query.limit < 1 || query.limit > 100
243
+ || query.actions.length === 0
244
+ || query.actions.some((action) => !PROJECT_LIFECYCLE_ACTIONS.has(action))) {
245
+ throw new RangeError("Invalid project lifecycle event page");
246
+ }
247
+ if (query.before) {
248
+ try { cursorMilliseconds = Date.prototype.getTime.call(query.before.createdAt) as number; }
249
+ catch { throw new RangeError("Invalid project lifecycle event cursor"); }
250
+ if (!Number.isFinite(cursorMilliseconds) || typeof query.before.id !== "string" || query.before.id.length === 0) {
251
+ throw new RangeError("Invalid project lifecycle event cursor");
252
+ }
253
+ }
254
+ const cursor = cursorMilliseconds === null ? null : new Date(cursorMilliseconds);
255
+ await this.ensureInitialized();
256
+ const result = await this.pool.query(
257
+ `SELECT * FROM lattice_task_work_items
258
+ WHERE tenant_id=$1 AND workspace_id=$2 AND project_id=$3
259
+ AND action = ANY($4::text[]) AND event_key IS NOT NULL AND event_key <> ''
260
+ AND ($5::timestamptz IS NULL OR created_at < $5::timestamptz
261
+ OR (created_at = $5::timestamptz AND id < $6))
262
+ ORDER BY created_at DESC, id DESC LIMIT $7`,
263
+ [query.tenantId, query.workspaceId, query.projectId, query.actions,
264
+ cursor, query.before?.id ?? null, query.limit],
265
+ );
266
+ return result.rows.map((row: Record<string, unknown>) => this.assertProjectLifecycleEvent(this.rowToItem(row)));
267
+ }
268
+
269
+ private assertProjectLifecycleEvent(item: TaskWorkItem): TaskWorkItem {
270
+ let milliseconds: number;
271
+ try { milliseconds = Date.prototype.getTime.call(item.createdAt) as number; }
272
+ catch { throw new Error("Invalid project lifecycle event row"); }
273
+ if (!Number.isFinite(milliseconds) || typeof item.id !== "string" || item.id.length === 0
274
+ || typeof item.eventKey !== "string" || item.eventKey.length === 0) {
275
+ throw new Error("Invalid project lifecycle event row");
276
+ }
277
+ return { ...item, createdAt: new Date(milliseconds) };
278
+ }
279
+
129
280
  private rowToItem(row: Record<string, unknown>): TaskWorkItem {
130
281
  return {
131
282
  id: row.id as string,
@@ -135,7 +286,7 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
135
286
  actor: row.actor as string,
136
287
  threadId: row.thread_id as string | undefined,
137
288
  summary: row.summary as string | undefined,
138
- detail: row.detail as Record<string, unknown> | undefined,
289
+ detail: isRecord(row.detail) ? row.detail : undefined,
139
290
  attempt: row.attempt as number | undefined,
140
291
  workspaceId: row.workspace_id as string | undefined,
141
292
  projectId: row.project_id as string | undefined,