diff --git a/server/src/__tests__/clips-service.test.ts b/server/src/__tests__/clips-service.test.ts index a4cfe46f7a1..b8c99d6d8cb 100644 --- a/server/src/__tests__/clips-service.test.ts +++ b/server/src/__tests__/clips-service.test.ts @@ -138,6 +138,39 @@ describeEmbeddedPostgres("clipService", () => { expect(detail).not.toHaveProperty("sourceObjectId"); }); + it("rolls back initial publish when revision dependency insertion fails", async () => { + const companyId = await seedCompany(); + const slug = "rollback-test-" + companyId.slice(0, 8); + + await expect(svc.publish(companyId, { + creatorProfile: { + handle: "rollback-creator-" + companyId.slice(0, 8), + displayName: "Rollback Creator", + }, + slug, + type: "agent", + title: "Rollback Test Agent", + summary: "Exercises transaction rollback on publish.", + visibility: "public", + status: "published", + sourceKind: "paperclip_company_object", + sourceObjectType: "agent", + sourceObjectId: "rollback-agent-id", + revision: { + manifestChecksum: "sha256:manifest-rollback", + artifactChecksum: "sha256:artifact-rollback", + manifestPayload: { schema: "paperclip.clip/v1", rollback: true }, + dependencies: [ + { type: "adapter", key: "codex_local" }, + { type: "adapter", key: "codex_local" }, + ], + }, + })).rejects.toThrow(); + + const rows = await db.select().from(clips).where(eq(clips.slug, slug)); + expect(rows).toHaveLength(0); + }); + it("stores revisions append-only and serves exact revision manifests", async () => { const companyId = await seedCompany(); const { clip, revision } = await publishFixture(companyId); diff --git a/server/src/services/clips.ts b/server/src/services/clips.ts index 6f4a1fcdddb..98d4275f8a4 100644 --- a/server/src/services/clips.ts +++ b/server/src/services/clips.ts @@ -336,39 +336,42 @@ export function clipService(db: Db) { const existing = await getClipBySlug(input.slug); if (existing) throw conflict("Clip slug already exists"); const creatorProfile = await resolveCreatorProfile(companyId, input); - const [clip] = await db - .insert(clips) - .values({ - sourceCompanyId: companyId, - creatorProfileId: creatorProfile.id, - slug: input.slug, - type: input.type, - title: input.title, - summary: input.summary, - description: input.description ?? null, - visibility: input.visibility ?? "unlisted", - status: input.status ?? (input.visibility === "public" ? "pending_review" : "published"), - tags: input.tags ?? [], - categories: input.categories ?? [], - useCases: input.useCases ?? [], - requiredProviders: input.requiredProviders ?? [], - compatibility: input.compatibility ?? {}, - sourceKind: input.sourceKind ?? null, - sourceObjectType: input.sourceObjectType ?? null, - sourceObjectId: input.sourceObjectId ?? null, - }) - .returning(); - const revision = await insertRevision(db, clip.id, input.revision, 1); - const visibleRevisionId = clip.status === "published" ? revision.id : null; - const [updated] = await db - .update(clips) - .set({ - currentRevisionId: revision.id, - latestApprovedRevisionId: visibleRevisionId, - updatedAt: new Date(), - }) - .where(eq(clips.id, clip.id)) - .returning(); + const { clip: updated, revision } = await db.transaction(async (tx) => { + const [clip] = await tx + .insert(clips) + .values({ + sourceCompanyId: companyId, + creatorProfileId: creatorProfile.id, + slug: input.slug, + type: input.type, + title: input.title, + summary: input.summary, + description: input.description ?? null, + visibility: input.visibility ?? "unlisted", + status: input.status ?? (input.visibility === "public" ? "pending_review" : "published"), + tags: input.tags ?? [], + categories: input.categories ?? [], + useCases: input.useCases ?? [], + requiredProviders: input.requiredProviders ?? [], + compatibility: input.compatibility ?? {}, + sourceKind: input.sourceKind ?? null, + sourceObjectType: input.sourceObjectType ?? null, + sourceObjectId: input.sourceObjectId ?? null, + }) + .returning(); + const revision = await insertRevision(tx, clip.id, input.revision, 1); + const visibleRevisionId = clip.status === "published" ? revision.id : null; + const [updated] = await tx + .update(clips) + .set({ + currentRevisionId: revision.id, + latestApprovedRevisionId: visibleRevisionId, + updatedAt: new Date(), + }) + .where(eq(clips.id, clip.id)) + .returning(); + return { clip: updated, revision }; + }); await rebuildRankingSnapshot(updated); return { clip: updated, revision, creatorProfile }; }