@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +9 -0
- package/dist/index.d.mts +195 -15
- package/dist/index.d.ts +195 -15
- package/dist/index.js +1309 -199
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1295 -191
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ChannelBindingStore.test.ts +122 -0
- package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
- package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
- package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
- package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
- package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
- package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +74 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +96 -0
- package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +113 -0
- package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
- package/src/__tests__/task-files.test.ts +4 -3
- package/src/createPgStoreConfig.ts +14 -0
- package/src/index.ts +7 -0
- package/src/migrations/add_trusted_run_context_column.ts +18 -0
- package/src/migrations/migration.ts +2 -1
- package/src/migrations/project_room_migration.ts +128 -0
- package/src/migrations/task_migration.ts +15 -0
- package/src/migrations/task_work_items_migration.ts +29 -1
- package/src/stores/ChannelBindingStore.ts +99 -59
- package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
- package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
- package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
- package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
- package/src/stores/PostgreSQLTaskStore.ts +89 -3
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +159 -8
- 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
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
|
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,
|