@opengeni/db 0.16.2 → 0.18.1
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/dist/{chunk-4RUK5CON.js → chunk-T6RSJT6C.js} +20 -2
- package/dist/chunk-T6RSJT6C.js.map +1 -0
- package/dist/{chunk-HZD7KVAR.js → chunk-YKWJ7QJ2.js} +369 -17
- package/dist/chunk-YKWJ7QJ2.js.map +1 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +3735 -2488
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.js +1 -1
- package/dist/runtime-posture.d.ts +3 -3
- package/dist/schema.d.ts +2631 -749
- package/dist/schema.js +15 -1
- package/dist/workspace-artifacts.d.ts +62 -0
- package/drizzle/0141_social_connection_credentials.sql +8 -0
- package/drizzle/0149_workspace_artifacts.sql +348 -0
- package/drizzle/0150_slack_task_interactions.sql +344 -0
- package/drizzle/0151_slack_delivery_backoff.sql +134 -0
- package/package.json +3 -3
- package/src/index.ts +1217 -12
- package/src/runtime-posture.ts +19 -1
- package/src/schema.ts +409 -16
- package/src/workspace-artifacts.ts +751 -0
- package/dist/chunk-4RUK5CON.js.map +0 -1
- package/dist/chunk-HZD7KVAR.js.map +0 -1
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkspaceArtifact,
|
|
3
|
+
WorkspaceArtifactDetailResponse,
|
|
4
|
+
WorkspaceArtifactEvent,
|
|
5
|
+
WorkspaceArtifactMutationResponse,
|
|
6
|
+
WorkspaceArtifactVersion,
|
|
7
|
+
} from "@opengeni/contracts";
|
|
8
|
+
import { and, desc, eq, lt, or, sql } from "drizzle-orm";
|
|
9
|
+
import type { Database } from "./index";
|
|
10
|
+
import { withRlsContext, withWorkspaceRls } from "./index";
|
|
11
|
+
import * as schema from "./schema";
|
|
12
|
+
|
|
13
|
+
type ArtifactRow = typeof schema.workspaceArtifacts.$inferSelect;
|
|
14
|
+
type VersionRow = typeof schema.workspaceArtifactVersions.$inferSelect;
|
|
15
|
+
type EventRow = typeof schema.workspaceArtifactEvents.$inferSelect;
|
|
16
|
+
type ArtifactMutationToolName = "artifacts_create" | "artifacts_publish" | "artifacts_rollback";
|
|
17
|
+
|
|
18
|
+
export class WorkspaceArtifactNotFoundError extends Error {
|
|
19
|
+
readonly name = "WorkspaceArtifactNotFoundError";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class WorkspaceArtifactConflictError extends Error {
|
|
23
|
+
readonly name = "WorkspaceArtifactConflictError";
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
readonly currentVersionId: string | null = null,
|
|
27
|
+
) {
|
|
28
|
+
super(message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class WorkspaceArtifactOperationError extends Error {
|
|
33
|
+
readonly name = "WorkspaceArtifactOperationError";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function iso(value: Date | string): string {
|
|
37
|
+
return (value instanceof Date ? value : new Date(value)).toISOString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function versionFromRow(row: VersionRow): WorkspaceArtifactVersion {
|
|
41
|
+
return {
|
|
42
|
+
id: row.id,
|
|
43
|
+
accountId: row.accountId,
|
|
44
|
+
workspaceId: row.workspaceId,
|
|
45
|
+
artifactId: row.artifactId,
|
|
46
|
+
revision: row.revision,
|
|
47
|
+
contentType: "text/html",
|
|
48
|
+
contentSha256: row.contentSha256,
|
|
49
|
+
sizeBytes: row.sizeBytes,
|
|
50
|
+
sourceSessionId: row.sourceSessionId,
|
|
51
|
+
sourceTurnId: row.sourceTurnId,
|
|
52
|
+
sourceAttemptId: row.sourceAttemptId,
|
|
53
|
+
sourceExecutionGeneration: row.sourceExecutionGeneration,
|
|
54
|
+
createdBySubjectId: row.createdBySubjectId,
|
|
55
|
+
createdAt: iso(row.createdAt),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function eventFromRow(row: EventRow): WorkspaceArtifactEvent {
|
|
60
|
+
return {
|
|
61
|
+
id: row.id,
|
|
62
|
+
accountId: row.accountId,
|
|
63
|
+
workspaceId: row.workspaceId,
|
|
64
|
+
artifactId: row.artifactId,
|
|
65
|
+
type: row.type,
|
|
66
|
+
fromVersionId: row.fromVersionId,
|
|
67
|
+
toVersionId: row.toVersionId,
|
|
68
|
+
sourceSessionId: row.sourceSessionId,
|
|
69
|
+
sourceTurnId: row.sourceTurnId,
|
|
70
|
+
sourceAttemptId: row.sourceAttemptId,
|
|
71
|
+
sourceExecutionGeneration: row.sourceExecutionGeneration,
|
|
72
|
+
actorSubjectId: row.actorSubjectId,
|
|
73
|
+
reason: row.reason,
|
|
74
|
+
createdAt: iso(row.createdAt),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function artifactFromRow(row: ArtifactRow, version: VersionRow | null): WorkspaceArtifact {
|
|
79
|
+
return {
|
|
80
|
+
id: row.id,
|
|
81
|
+
accountId: row.accountId,
|
|
82
|
+
workspaceId: row.workspaceId,
|
|
83
|
+
slug: row.slug,
|
|
84
|
+
title: row.title,
|
|
85
|
+
description: row.description,
|
|
86
|
+
status: row.status,
|
|
87
|
+
currentVersion: version ? versionFromRow(version) : null,
|
|
88
|
+
createdBySubjectId: row.createdBySubjectId,
|
|
89
|
+
createdAt: iso(row.createdAt),
|
|
90
|
+
updatedAt: iso(row.updatedAt),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function currentVersion(scopedDb: any, artifact: ArtifactRow): Promise<VersionRow | null> {
|
|
95
|
+
if (!artifact.currentVersionId) return null;
|
|
96
|
+
const [row] = await scopedDb
|
|
97
|
+
.select()
|
|
98
|
+
.from(schema.workspaceArtifactVersions)
|
|
99
|
+
.where(
|
|
100
|
+
and(
|
|
101
|
+
eq(schema.workspaceArtifactVersions.workspaceId, artifact.workspaceId),
|
|
102
|
+
eq(schema.workspaceArtifactVersions.id, artifact.currentVersionId),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
.limit(1);
|
|
106
|
+
return row ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function artifactRow(scopedDb: any, workspaceId: string, artifactId: string, lock = false) {
|
|
110
|
+
let query = scopedDb
|
|
111
|
+
.select()
|
|
112
|
+
.from(schema.workspaceArtifacts)
|
|
113
|
+
.where(
|
|
114
|
+
and(
|
|
115
|
+
eq(schema.workspaceArtifacts.workspaceId, workspaceId),
|
|
116
|
+
eq(schema.workspaceArtifacts.id, artifactId),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
.limit(1);
|
|
120
|
+
if (lock) query = query.for("update");
|
|
121
|
+
const [row] = await query;
|
|
122
|
+
return (row as ArtifactRow | undefined) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function listWorkspaceArtifacts(
|
|
126
|
+
db: Database,
|
|
127
|
+
workspaceId: string,
|
|
128
|
+
options: { limit?: number; cursor?: string } = {},
|
|
129
|
+
): Promise<{ artifacts: WorkspaceArtifact[]; nextCursor: string | null; truncated: boolean }> {
|
|
130
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
131
|
+
const limit = Math.min(Math.max(options.limit ?? 50, 1), 100);
|
|
132
|
+
const cursor = options.cursor ? decodeListCursor(options.cursor) : null;
|
|
133
|
+
const visibility = cursor
|
|
134
|
+
? and(
|
|
135
|
+
eq(schema.workspaceArtifacts.workspaceId, workspaceId),
|
|
136
|
+
or(
|
|
137
|
+
lt(schema.workspaceArtifacts.updatedAt, cursor.updatedAt),
|
|
138
|
+
and(
|
|
139
|
+
eq(schema.workspaceArtifacts.updatedAt, cursor.updatedAt),
|
|
140
|
+
lt(schema.workspaceArtifacts.id, cursor.id),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
: eq(schema.workspaceArtifacts.workspaceId, workspaceId);
|
|
145
|
+
const rows = await scopedDb
|
|
146
|
+
.select()
|
|
147
|
+
.from(schema.workspaceArtifacts)
|
|
148
|
+
.where(visibility)
|
|
149
|
+
.orderBy(desc(schema.workspaceArtifacts.updatedAt), desc(schema.workspaceArtifacts.id))
|
|
150
|
+
.limit(limit + 1);
|
|
151
|
+
const truncated = rows.length > limit;
|
|
152
|
+
const pageRows = rows.slice(0, limit);
|
|
153
|
+
const artifacts = await Promise.all(
|
|
154
|
+
pageRows.map(async (row) => artifactFromRow(row, await currentVersion(scopedDb, row))),
|
|
155
|
+
);
|
|
156
|
+
const tail = truncated ? pageRows.at(-1) : null;
|
|
157
|
+
return {
|
|
158
|
+
artifacts,
|
|
159
|
+
nextCursor: tail ? encodeListCursor(tail) : null,
|
|
160
|
+
truncated,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const artifactCursorId =
|
|
166
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
167
|
+
|
|
168
|
+
function encodeListCursor(row: ArtifactRow): string {
|
|
169
|
+
return Buffer.from(JSON.stringify([iso(row.updatedAt), row.id]), "utf8").toString("base64url");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function decodeListCursor(value: string): { updatedAt: Date; id: string } {
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
175
|
+
if (
|
|
176
|
+
!Array.isArray(parsed) ||
|
|
177
|
+
parsed.length !== 2 ||
|
|
178
|
+
typeof parsed[0] !== "string" ||
|
|
179
|
+
typeof parsed[1] !== "string" ||
|
|
180
|
+
!artifactCursorId.test(parsed[1])
|
|
181
|
+
) {
|
|
182
|
+
throw new Error("invalid shape");
|
|
183
|
+
}
|
|
184
|
+
const updatedAt = new Date(parsed[0]);
|
|
185
|
+
if (!Number.isFinite(updatedAt.getTime())) throw new Error("invalid timestamp");
|
|
186
|
+
return { updatedAt, id: parsed[1] };
|
|
187
|
+
} catch {
|
|
188
|
+
throw new WorkspaceArtifactOperationError("Invalid artifact list cursor");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function getWorkspaceArtifact(
|
|
193
|
+
db: Database,
|
|
194
|
+
workspaceId: string,
|
|
195
|
+
artifactId: string,
|
|
196
|
+
): Promise<WorkspaceArtifactDetailResponse> {
|
|
197
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
198
|
+
const artifact = await artifactRow(scopedDb, workspaceId, artifactId);
|
|
199
|
+
if (!artifact) throw new WorkspaceArtifactNotFoundError("Artifact not found");
|
|
200
|
+
const [versionRows, eventRows, current] = await Promise.all([
|
|
201
|
+
scopedDb
|
|
202
|
+
.select()
|
|
203
|
+
.from(schema.workspaceArtifactVersions)
|
|
204
|
+
.where(
|
|
205
|
+
and(
|
|
206
|
+
eq(schema.workspaceArtifactVersions.workspaceId, workspaceId),
|
|
207
|
+
eq(schema.workspaceArtifactVersions.artifactId, artifactId),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
.orderBy(desc(schema.workspaceArtifactVersions.revision))
|
|
211
|
+
.limit(101),
|
|
212
|
+
scopedDb
|
|
213
|
+
.select()
|
|
214
|
+
.from(schema.workspaceArtifactEvents)
|
|
215
|
+
.where(
|
|
216
|
+
and(
|
|
217
|
+
eq(schema.workspaceArtifactEvents.workspaceId, workspaceId),
|
|
218
|
+
eq(schema.workspaceArtifactEvents.artifactId, artifactId),
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
.orderBy(desc(schema.workspaceArtifactEvents.createdAt))
|
|
222
|
+
.limit(101),
|
|
223
|
+
currentVersion(scopedDb, artifact),
|
|
224
|
+
]);
|
|
225
|
+
return {
|
|
226
|
+
artifact: artifactFromRow(artifact, current),
|
|
227
|
+
versions: versionRows.slice(0, 100).map(versionFromRow),
|
|
228
|
+
events: eventRows.slice(0, 100).map(eventFromRow),
|
|
229
|
+
versionsTruncated: versionRows.length > 100,
|
|
230
|
+
eventsTruncated: eventRows.length > 100,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function getWorkspaceArtifactContentRef(
|
|
236
|
+
db: Database,
|
|
237
|
+
workspaceId: string,
|
|
238
|
+
artifactId: string,
|
|
239
|
+
versionId?: string,
|
|
240
|
+
): Promise<{ artifactId: string; version: WorkspaceArtifactVersion; contentKey: string }> {
|
|
241
|
+
return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
|
|
242
|
+
const artifact = await artifactRow(scopedDb, workspaceId, artifactId);
|
|
243
|
+
if (!artifact) throw new WorkspaceArtifactNotFoundError("Artifact not found");
|
|
244
|
+
const targetId = versionId ?? artifact.currentVersionId;
|
|
245
|
+
if (!targetId) throw new WorkspaceArtifactNotFoundError("Artifact has no published version");
|
|
246
|
+
const [version] = await scopedDb
|
|
247
|
+
.select()
|
|
248
|
+
.from(schema.workspaceArtifactVersions)
|
|
249
|
+
.where(
|
|
250
|
+
and(
|
|
251
|
+
eq(schema.workspaceArtifactVersions.workspaceId, workspaceId),
|
|
252
|
+
eq(schema.workspaceArtifactVersions.artifactId, artifactId),
|
|
253
|
+
eq(schema.workspaceArtifactVersions.id, targetId),
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
.limit(1);
|
|
257
|
+
if (!version) throw new WorkspaceArtifactNotFoundError("Artifact version not found");
|
|
258
|
+
return { artifactId, version: versionFromRow(version), contentKey: version.contentKey };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type PublishMetadata = {
|
|
263
|
+
accountId: string;
|
|
264
|
+
workspaceId: string;
|
|
265
|
+
contentKey: string;
|
|
266
|
+
contentSha256: string;
|
|
267
|
+
sizeBytes: number;
|
|
268
|
+
operationKey: string;
|
|
269
|
+
actorSubjectId: string;
|
|
270
|
+
sourceSessionId: string | null;
|
|
271
|
+
sourceTurnId: string | null;
|
|
272
|
+
sourceAttemptId: string | null;
|
|
273
|
+
sourceExecutionGeneration: number | null;
|
|
274
|
+
sourceToolName: ArtifactMutationToolName | null;
|
|
275
|
+
persistContent: () => Promise<void>;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
async function assertAttemptAuthority(
|
|
279
|
+
scopedDb: any,
|
|
280
|
+
input: Pick<
|
|
281
|
+
PublishMetadata,
|
|
282
|
+
| "accountId"
|
|
283
|
+
| "workspaceId"
|
|
284
|
+
| "actorSubjectId"
|
|
285
|
+
| "sourceSessionId"
|
|
286
|
+
| "sourceTurnId"
|
|
287
|
+
| "sourceAttemptId"
|
|
288
|
+
| "sourceExecutionGeneration"
|
|
289
|
+
| "sourceToolName"
|
|
290
|
+
>,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const provenance = [
|
|
293
|
+
input.sourceSessionId,
|
|
294
|
+
input.sourceTurnId,
|
|
295
|
+
input.sourceAttemptId,
|
|
296
|
+
input.sourceExecutionGeneration,
|
|
297
|
+
];
|
|
298
|
+
if (provenance.every((value) => value === null)) return;
|
|
299
|
+
if (provenance.some((value) => value === null) || input.sourceToolName === null) {
|
|
300
|
+
throw new WorkspaceArtifactOperationError("Artifact attempt provenance is incomplete");
|
|
301
|
+
}
|
|
302
|
+
const rows = (await scopedDb.execute(sql`
|
|
303
|
+
WITH locked_workspace AS MATERIALIZED (
|
|
304
|
+
SELECT workspace.id, workspace.account_id
|
|
305
|
+
FROM workspaces workspace
|
|
306
|
+
WHERE workspace.id = ${input.workspaceId}::uuid
|
|
307
|
+
AND workspace.account_id = ${input.accountId}::uuid
|
|
308
|
+
FOR KEY SHARE OF workspace
|
|
309
|
+
), locked_session AS MATERIALIZED (
|
|
310
|
+
SELECT session.id, session.account_id, session.workspace_id, session.active_turn_id,
|
|
311
|
+
session.first_party_mcp_tools, session.first_party_mcp_permissions
|
|
312
|
+
FROM sessions session
|
|
313
|
+
JOIN locked_workspace workspace
|
|
314
|
+
ON workspace.id = session.workspace_id
|
|
315
|
+
AND workspace.account_id = session.account_id
|
|
316
|
+
WHERE session.id = ${input.sourceSessionId}::uuid
|
|
317
|
+
AND session.active_turn_id = ${input.sourceTurnId}::uuid
|
|
318
|
+
AND session.first_party_mcp_tools @> jsonb_build_array(${input.sourceToolName}::text)
|
|
319
|
+
AND (
|
|
320
|
+
session.first_party_mcp_permissions IS NULL
|
|
321
|
+
OR session.first_party_mcp_permissions @> '["artifacts:publish"]'::jsonb
|
|
322
|
+
)
|
|
323
|
+
FOR UPDATE OF session
|
|
324
|
+
), locked_turn AS MATERIALIZED (
|
|
325
|
+
SELECT turn.id, turn.account_id, turn.workspace_id, turn.session_id,
|
|
326
|
+
turn.active_attempt_id, turn.execution_generation, turn.initiator_subject_id
|
|
327
|
+
FROM session_turns turn
|
|
328
|
+
JOIN locked_session session
|
|
329
|
+
ON session.id = turn.session_id
|
|
330
|
+
AND session.workspace_id = turn.workspace_id
|
|
331
|
+
AND session.account_id = turn.account_id
|
|
332
|
+
WHERE turn.id = ${input.sourceTurnId}::uuid
|
|
333
|
+
AND turn.active_attempt_id = ${input.sourceAttemptId}::uuid
|
|
334
|
+
AND turn.execution_generation = ${input.sourceExecutionGeneration}
|
|
335
|
+
AND turn.status IN ('running', 'requires_action', 'recovering', 'waiting_capacity')
|
|
336
|
+
AND turn.initiator_kind = 'subject'
|
|
337
|
+
AND length(btrim(turn.initiator_subject_id)) BETWEEN 1 AND 1024
|
|
338
|
+
FOR UPDATE OF turn
|
|
339
|
+
), locked_attempt AS MATERIALIZED (
|
|
340
|
+
SELECT attempt.id, attempt.account_id, attempt.workspace_id,
|
|
341
|
+
attempt.session_id, attempt.turn_id, attempt.execution_generation
|
|
342
|
+
FROM session_turn_attempts attempt
|
|
343
|
+
JOIN locked_turn turn
|
|
344
|
+
ON turn.id = attempt.turn_id
|
|
345
|
+
AND turn.session_id = attempt.session_id
|
|
346
|
+
AND turn.workspace_id = attempt.workspace_id
|
|
347
|
+
AND turn.account_id = attempt.account_id
|
|
348
|
+
WHERE attempt.id = ${input.sourceAttemptId}::uuid
|
|
349
|
+
AND attempt.execution_generation = ${input.sourceExecutionGeneration}
|
|
350
|
+
AND attempt.state IN ('claimed', 'running')
|
|
351
|
+
AND NOT EXISTS (
|
|
352
|
+
SELECT 1 FROM session_attempt_interruptions interruption
|
|
353
|
+
WHERE interruption.workspace_id = attempt.workspace_id
|
|
354
|
+
AND interruption.attempt_id = attempt.id
|
|
355
|
+
AND interruption.state IN ('pending', 'delivered', 'acknowledged')
|
|
356
|
+
)
|
|
357
|
+
FOR UPDATE OF attempt
|
|
358
|
+
)
|
|
359
|
+
SELECT attempt.id
|
|
360
|
+
FROM locked_session session
|
|
361
|
+
JOIN locked_turn turn ON true
|
|
362
|
+
JOIN locked_attempt attempt ON true
|
|
363
|
+
`)) as unknown as Array<{ id: string }>;
|
|
364
|
+
if (!rows[0]) {
|
|
365
|
+
throw new WorkspaceArtifactOperationError(
|
|
366
|
+
"Artifact mutation requires the exact active attempt and execution generation",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function replayForOperation(scopedDb: any, workspaceId: string, operationKey: string) {
|
|
372
|
+
const [event] = await scopedDb
|
|
373
|
+
.select()
|
|
374
|
+
.from(schema.workspaceArtifactEvents)
|
|
375
|
+
.where(
|
|
376
|
+
and(
|
|
377
|
+
eq(schema.workspaceArtifactEvents.workspaceId, workspaceId),
|
|
378
|
+
eq(schema.workspaceArtifactEvents.operationKey, operationKey),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
.limit(1);
|
|
382
|
+
if (!event) return null;
|
|
383
|
+
const artifact = await artifactRow(scopedDb, workspaceId, event.artifactId);
|
|
384
|
+
if (!artifact) return null;
|
|
385
|
+
const [version] = await scopedDb
|
|
386
|
+
.select()
|
|
387
|
+
.from(schema.workspaceArtifactVersions)
|
|
388
|
+
.where(eq(schema.workspaceArtifactVersions.id, event.toVersionId))
|
|
389
|
+
.limit(1);
|
|
390
|
+
if (!version) return null;
|
|
391
|
+
return { artifact, version, event, current: await currentVersion(scopedDb, artifact) } as const;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function lockOperation(scopedDb: any, workspaceId: string, operationKey: string) {
|
|
395
|
+
await scopedDb.execute(
|
|
396
|
+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${workspaceId}:${operationKey}`}, 0))`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function assertCreateReplay(replay: Awaited<ReturnType<typeof replayForOperation>>) {
|
|
401
|
+
if (
|
|
402
|
+
!replay ||
|
|
403
|
+
replay.event.type !== "published" ||
|
|
404
|
+
replay.event.fromVersionId !== null ||
|
|
405
|
+
replay.version.revision !== 1
|
|
406
|
+
) {
|
|
407
|
+
throw new WorkspaceArtifactConflictError(
|
|
408
|
+
"Idempotency key was already used for a different operation",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function assertPublishReplay(
|
|
414
|
+
replay: Awaited<ReturnType<typeof replayForOperation>>,
|
|
415
|
+
artifactId: string,
|
|
416
|
+
expectedCurrentVersionId: string,
|
|
417
|
+
) {
|
|
418
|
+
if (
|
|
419
|
+
!replay ||
|
|
420
|
+
replay.event.type !== "published" ||
|
|
421
|
+
replay.event.artifactId !== artifactId ||
|
|
422
|
+
replay.event.fromVersionId !== expectedCurrentVersionId
|
|
423
|
+
) {
|
|
424
|
+
throw new WorkspaceArtifactConflictError(
|
|
425
|
+
"Idempotency key was already used for a different operation",
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function assertRollbackReplay(
|
|
431
|
+
replay: Awaited<ReturnType<typeof replayForOperation>>,
|
|
432
|
+
artifactId: string,
|
|
433
|
+
versionId: string,
|
|
434
|
+
expectedCurrentVersionId: string,
|
|
435
|
+
reason: string,
|
|
436
|
+
) {
|
|
437
|
+
if (
|
|
438
|
+
!replay ||
|
|
439
|
+
replay.event.type !== "rolled_back" ||
|
|
440
|
+
replay.event.artifactId !== artifactId ||
|
|
441
|
+
replay.event.toVersionId !== versionId ||
|
|
442
|
+
replay.event.fromVersionId !== expectedCurrentVersionId ||
|
|
443
|
+
replay.event.reason !== reason
|
|
444
|
+
) {
|
|
445
|
+
throw new WorkspaceArtifactConflictError(
|
|
446
|
+
"Idempotency key was already used for a different operation",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function mutationResult(
|
|
452
|
+
artifact: ArtifactRow,
|
|
453
|
+
version: VersionRow,
|
|
454
|
+
event: EventRow,
|
|
455
|
+
replayed: boolean,
|
|
456
|
+
displayedCurrentVersion: VersionRow = version,
|
|
457
|
+
): WorkspaceArtifactMutationResponse {
|
|
458
|
+
return {
|
|
459
|
+
artifact: artifactFromRow(artifact, displayedCurrentVersion),
|
|
460
|
+
version: versionFromRow(version),
|
|
461
|
+
event: eventFromRow(event),
|
|
462
|
+
replayed,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function createWorkspaceArtifact(
|
|
467
|
+
db: Database,
|
|
468
|
+
input: PublishMetadata & {
|
|
469
|
+
artifactId: string;
|
|
470
|
+
slug: string;
|
|
471
|
+
title: string;
|
|
472
|
+
description: string | null;
|
|
473
|
+
},
|
|
474
|
+
): Promise<WorkspaceArtifactMutationResponse> {
|
|
475
|
+
return await withRlsContext(
|
|
476
|
+
db,
|
|
477
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
478
|
+
async (scopedDb) => {
|
|
479
|
+
return await scopedDb.transaction(async (tx) => {
|
|
480
|
+
await lockOperation(tx, input.workspaceId, input.operationKey);
|
|
481
|
+
await assertAttemptAuthority(tx, input);
|
|
482
|
+
const replay = await replayForOperation(tx, input.workspaceId, input.operationKey);
|
|
483
|
+
if (replay) {
|
|
484
|
+
assertCreateReplay(replay);
|
|
485
|
+
if (replay.version.contentSha256 !== input.contentSha256) {
|
|
486
|
+
throw new WorkspaceArtifactConflictError(
|
|
487
|
+
"Idempotency key was already used with different content",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return mutationResult(
|
|
491
|
+
replay.artifact,
|
|
492
|
+
replay.version,
|
|
493
|
+
replay.event,
|
|
494
|
+
true,
|
|
495
|
+
replay.current ?? replay.version,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
await input.persistContent();
|
|
499
|
+
const [artifact] = await tx
|
|
500
|
+
.insert(schema.workspaceArtifacts)
|
|
501
|
+
.values({
|
|
502
|
+
id: input.artifactId,
|
|
503
|
+
accountId: input.accountId,
|
|
504
|
+
workspaceId: input.workspaceId,
|
|
505
|
+
slug: input.slug,
|
|
506
|
+
title: input.title,
|
|
507
|
+
description: input.description,
|
|
508
|
+
createdBySubjectId: input.actorSubjectId,
|
|
509
|
+
})
|
|
510
|
+
.returning();
|
|
511
|
+
const [version] = await tx
|
|
512
|
+
.insert(schema.workspaceArtifactVersions)
|
|
513
|
+
.values({
|
|
514
|
+
accountId: input.accountId,
|
|
515
|
+
workspaceId: input.workspaceId,
|
|
516
|
+
artifactId: input.artifactId,
|
|
517
|
+
revision: 1,
|
|
518
|
+
contentKey: input.contentKey,
|
|
519
|
+
contentSha256: input.contentSha256,
|
|
520
|
+
sizeBytes: input.sizeBytes,
|
|
521
|
+
operationKey: input.operationKey,
|
|
522
|
+
sourceSessionId: input.sourceSessionId,
|
|
523
|
+
sourceTurnId: input.sourceTurnId,
|
|
524
|
+
sourceAttemptId: input.sourceAttemptId,
|
|
525
|
+
sourceExecutionGeneration: input.sourceExecutionGeneration,
|
|
526
|
+
createdBySubjectId: input.actorSubjectId,
|
|
527
|
+
})
|
|
528
|
+
.returning();
|
|
529
|
+
const [updated] = await tx
|
|
530
|
+
.update(schema.workspaceArtifacts)
|
|
531
|
+
.set({
|
|
532
|
+
currentVersionId: version!.id,
|
|
533
|
+
updatedAt: new Date(),
|
|
534
|
+
})
|
|
535
|
+
.where(eq(schema.workspaceArtifacts.id, artifact!.id))
|
|
536
|
+
.returning();
|
|
537
|
+
const [event] = await tx
|
|
538
|
+
.insert(schema.workspaceArtifactEvents)
|
|
539
|
+
.values({
|
|
540
|
+
accountId: input.accountId,
|
|
541
|
+
workspaceId: input.workspaceId,
|
|
542
|
+
artifactId: input.artifactId,
|
|
543
|
+
type: "published",
|
|
544
|
+
fromVersionId: null,
|
|
545
|
+
toVersionId: version!.id,
|
|
546
|
+
operationKey: input.operationKey,
|
|
547
|
+
sourceSessionId: input.sourceSessionId,
|
|
548
|
+
sourceTurnId: input.sourceTurnId,
|
|
549
|
+
sourceAttemptId: input.sourceAttemptId,
|
|
550
|
+
sourceExecutionGeneration: input.sourceExecutionGeneration,
|
|
551
|
+
actorSubjectId: input.actorSubjectId,
|
|
552
|
+
reason: "Initial publication",
|
|
553
|
+
})
|
|
554
|
+
.returning();
|
|
555
|
+
return mutationResult(updated!, version!, event!, false);
|
|
556
|
+
});
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export async function publishWorkspaceArtifactVersion(
|
|
562
|
+
db: Database,
|
|
563
|
+
input: PublishMetadata & {
|
|
564
|
+
artifactId: string;
|
|
565
|
+
expectedCurrentVersionId: string;
|
|
566
|
+
title?: string;
|
|
567
|
+
description?: string | null;
|
|
568
|
+
},
|
|
569
|
+
): Promise<WorkspaceArtifactMutationResponse> {
|
|
570
|
+
return await withRlsContext(
|
|
571
|
+
db,
|
|
572
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
573
|
+
async (scopedDb) => {
|
|
574
|
+
return await scopedDb.transaction(async (tx) => {
|
|
575
|
+
await lockOperation(tx, input.workspaceId, input.operationKey);
|
|
576
|
+
await assertAttemptAuthority(tx, input);
|
|
577
|
+
const replay = await replayForOperation(tx, input.workspaceId, input.operationKey);
|
|
578
|
+
if (replay) {
|
|
579
|
+
assertPublishReplay(replay, input.artifactId, input.expectedCurrentVersionId);
|
|
580
|
+
if (replay.version.contentSha256 !== input.contentSha256) {
|
|
581
|
+
throw new WorkspaceArtifactConflictError(
|
|
582
|
+
"Idempotency key was already used with different content",
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
return mutationResult(
|
|
586
|
+
replay.artifact,
|
|
587
|
+
replay.version,
|
|
588
|
+
replay.event,
|
|
589
|
+
true,
|
|
590
|
+
replay.current ?? replay.version,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
const artifact = await artifactRow(tx, input.workspaceId, input.artifactId, true);
|
|
594
|
+
if (!artifact) throw new WorkspaceArtifactNotFoundError("Artifact not found");
|
|
595
|
+
if (artifact.status !== "active") {
|
|
596
|
+
throw new WorkspaceArtifactOperationError("Archived artifacts cannot be published");
|
|
597
|
+
}
|
|
598
|
+
if (artifact.currentVersionId !== input.expectedCurrentVersionId) {
|
|
599
|
+
throw new WorkspaceArtifactConflictError(
|
|
600
|
+
"Artifact changed in another request",
|
|
601
|
+
artifact.currentVersionId,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
const [latest] = await tx
|
|
605
|
+
.select()
|
|
606
|
+
.from(schema.workspaceArtifactVersions)
|
|
607
|
+
.where(
|
|
608
|
+
and(
|
|
609
|
+
eq(schema.workspaceArtifactVersions.workspaceId, input.workspaceId),
|
|
610
|
+
eq(schema.workspaceArtifactVersions.artifactId, input.artifactId),
|
|
611
|
+
),
|
|
612
|
+
)
|
|
613
|
+
.orderBy(desc(schema.workspaceArtifactVersions.revision))
|
|
614
|
+
.limit(1);
|
|
615
|
+
await input.persistContent();
|
|
616
|
+
const [version] = await tx
|
|
617
|
+
.insert(schema.workspaceArtifactVersions)
|
|
618
|
+
.values({
|
|
619
|
+
accountId: input.accountId,
|
|
620
|
+
workspaceId: input.workspaceId,
|
|
621
|
+
artifactId: input.artifactId,
|
|
622
|
+
revision: (latest?.revision ?? 0) + 1,
|
|
623
|
+
contentKey: input.contentKey,
|
|
624
|
+
contentSha256: input.contentSha256,
|
|
625
|
+
sizeBytes: input.sizeBytes,
|
|
626
|
+
operationKey: input.operationKey,
|
|
627
|
+
sourceSessionId: input.sourceSessionId,
|
|
628
|
+
sourceTurnId: input.sourceTurnId,
|
|
629
|
+
sourceAttemptId: input.sourceAttemptId,
|
|
630
|
+
sourceExecutionGeneration: input.sourceExecutionGeneration,
|
|
631
|
+
createdBySubjectId: input.actorSubjectId,
|
|
632
|
+
})
|
|
633
|
+
.returning();
|
|
634
|
+
const update: Partial<typeof schema.workspaceArtifacts.$inferInsert> = {
|
|
635
|
+
currentVersionId: version!.id,
|
|
636
|
+
updatedAt: new Date(),
|
|
637
|
+
};
|
|
638
|
+
if (input.title !== undefined) update.title = input.title;
|
|
639
|
+
if (input.description !== undefined) update.description = input.description;
|
|
640
|
+
const [updated] = await tx
|
|
641
|
+
.update(schema.workspaceArtifacts)
|
|
642
|
+
.set(update)
|
|
643
|
+
.where(eq(schema.workspaceArtifacts.id, artifact.id))
|
|
644
|
+
.returning();
|
|
645
|
+
const [event] = await tx
|
|
646
|
+
.insert(schema.workspaceArtifactEvents)
|
|
647
|
+
.values({
|
|
648
|
+
accountId: input.accountId,
|
|
649
|
+
workspaceId: input.workspaceId,
|
|
650
|
+
artifactId: input.artifactId,
|
|
651
|
+
type: "published",
|
|
652
|
+
fromVersionId: artifact.currentVersionId,
|
|
653
|
+
toVersionId: version!.id,
|
|
654
|
+
operationKey: input.operationKey,
|
|
655
|
+
sourceSessionId: input.sourceSessionId,
|
|
656
|
+
sourceTurnId: input.sourceTurnId,
|
|
657
|
+
sourceAttemptId: input.sourceAttemptId,
|
|
658
|
+
sourceExecutionGeneration: input.sourceExecutionGeneration,
|
|
659
|
+
actorSubjectId: input.actorSubjectId,
|
|
660
|
+
reason: `Published revision ${version!.revision}`,
|
|
661
|
+
})
|
|
662
|
+
.returning();
|
|
663
|
+
return mutationResult(updated!, version!, event!, false);
|
|
664
|
+
});
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export async function rollbackWorkspaceArtifact(
|
|
670
|
+
db: Database,
|
|
671
|
+
input: Omit<PublishMetadata, "contentKey" | "contentSha256" | "sizeBytes" | "persistContent"> & {
|
|
672
|
+
artifactId: string;
|
|
673
|
+
versionId: string;
|
|
674
|
+
expectedCurrentVersionId: string;
|
|
675
|
+
reason: string;
|
|
676
|
+
},
|
|
677
|
+
): Promise<WorkspaceArtifactMutationResponse> {
|
|
678
|
+
return await withRlsContext(
|
|
679
|
+
db,
|
|
680
|
+
{ accountId: input.accountId, workspaceId: input.workspaceId },
|
|
681
|
+
async (scopedDb) => {
|
|
682
|
+
return await scopedDb.transaction(async (tx) => {
|
|
683
|
+
await lockOperation(tx, input.workspaceId, input.operationKey);
|
|
684
|
+
await assertAttemptAuthority(tx, input);
|
|
685
|
+
const replay = await replayForOperation(tx, input.workspaceId, input.operationKey);
|
|
686
|
+
if (replay) {
|
|
687
|
+
assertRollbackReplay(
|
|
688
|
+
replay,
|
|
689
|
+
input.artifactId,
|
|
690
|
+
input.versionId,
|
|
691
|
+
input.expectedCurrentVersionId,
|
|
692
|
+
input.reason,
|
|
693
|
+
);
|
|
694
|
+
return mutationResult(
|
|
695
|
+
replay.artifact,
|
|
696
|
+
replay.version,
|
|
697
|
+
replay.event,
|
|
698
|
+
true,
|
|
699
|
+
replay.current ?? replay.version,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const artifact = await artifactRow(tx, input.workspaceId, input.artifactId, true);
|
|
703
|
+
if (!artifact) throw new WorkspaceArtifactNotFoundError("Artifact not found");
|
|
704
|
+
if (artifact.status !== "active") {
|
|
705
|
+
throw new WorkspaceArtifactOperationError("Archived artifacts cannot be rolled back");
|
|
706
|
+
}
|
|
707
|
+
if (artifact.currentVersionId !== input.expectedCurrentVersionId)
|
|
708
|
+
throw new WorkspaceArtifactConflictError(
|
|
709
|
+
"Artifact changed in another request",
|
|
710
|
+
artifact.currentVersionId,
|
|
711
|
+
);
|
|
712
|
+
const [target] = await tx
|
|
713
|
+
.select()
|
|
714
|
+
.from(schema.workspaceArtifactVersions)
|
|
715
|
+
.where(
|
|
716
|
+
and(
|
|
717
|
+
eq(schema.workspaceArtifactVersions.workspaceId, input.workspaceId),
|
|
718
|
+
eq(schema.workspaceArtifactVersions.artifactId, input.artifactId),
|
|
719
|
+
eq(schema.workspaceArtifactVersions.id, input.versionId),
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
.limit(1);
|
|
723
|
+
if (!target) throw new WorkspaceArtifactNotFoundError("Artifact version not found");
|
|
724
|
+
const [updated] = await tx
|
|
725
|
+
.update(schema.workspaceArtifacts)
|
|
726
|
+
.set({ currentVersionId: target.id, updatedAt: new Date() })
|
|
727
|
+
.where(eq(schema.workspaceArtifacts.id, artifact.id))
|
|
728
|
+
.returning();
|
|
729
|
+
const [event] = await tx
|
|
730
|
+
.insert(schema.workspaceArtifactEvents)
|
|
731
|
+
.values({
|
|
732
|
+
accountId: input.accountId,
|
|
733
|
+
workspaceId: input.workspaceId,
|
|
734
|
+
artifactId: input.artifactId,
|
|
735
|
+
type: "rolled_back",
|
|
736
|
+
fromVersionId: artifact.currentVersionId,
|
|
737
|
+
toVersionId: target.id,
|
|
738
|
+
operationKey: input.operationKey,
|
|
739
|
+
sourceSessionId: input.sourceSessionId,
|
|
740
|
+
sourceTurnId: input.sourceTurnId,
|
|
741
|
+
sourceAttemptId: input.sourceAttemptId,
|
|
742
|
+
sourceExecutionGeneration: input.sourceExecutionGeneration,
|
|
743
|
+
actorSubjectId: input.actorSubjectId,
|
|
744
|
+
reason: input.reason,
|
|
745
|
+
})
|
|
746
|
+
.returning();
|
|
747
|
+
return mutationResult(updated!, target, event!, false);
|
|
748
|
+
});
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
}
|