@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.
@@ -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
+ }