Skip to content

Commit c69bd77

Browse files
feat(changelog): a deployment ships in one release; describe get as returning the body path
1 parent 57f7f2d commit c69bd77

3 files changed

Lines changed: 184 additions & 5 deletions

File tree

‎apps/sim/lib/api/contracts/mothership-management-tools.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const managementToolContracts = [
3333
route: 'sim',
3434
scope: 'all',
3535
description:
36-
'Publish and edit the selected workspace\u2019s changelog. publish takes a title, a markdown body, the changes (one line each, with the workflowId, deploymentVersionId, and chatId they came from when known), and bump: major, minor, or patch for how significant the release is, with a one-line bumpReason. The server computes the version from the workspace\u2019s highest one; never write a version number on publish. It returns the release with its revision and changelog/<id>.md, the body file: edit the body there with the file tools, for example to add impact charts once there is data. update changes the title, bumpReason, version label, or the whole change list, and requires expectedRevision from get, list, or publish so a concurrent edit is never overwritten. list pages newest first; get returns one release with its body.',
36+
'Publish and edit the selected workspace\u2019s changelog. publish takes a title, a markdown body, the changes (one line each, with the workflowId, deploymentVersionId, and chatId they came from when known), and bump: major, minor, or patch for how significant the release is, with a one-line bumpReason. The server computes the version from the workspace\u2019s highest one; never write a version number on publish. It returns the release with its revision and changelog/<id>.md, the body file: edit the body there with the file tools, for example to add impact charts once there is data. update changes the title, bumpReason, version label, or the whole change list, and requires expectedRevision from get, list, or publish so a concurrent edit is never overwritten. A deployment ships in one release: a deploymentVersionId already in another release is rejected. list pages newest first; get returns one release with its revision and its changelog/<id>.md path.',
3737
inputSchema: mothershipChangelogInputSchema,
3838
},
3939
{
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* A deployment ships in at most one release, against the disposable TEST_DATABASE_URL database:
3+
* the workspace lock, the cross-release check, and concurrent publishes all run for real.
4+
*/
5+
import { db } from '@sim/db'
6+
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
7+
import { createDeferred } from '@sim/testing/helpers/deferred'
8+
import { sleep } from '@sim/utils/helpers'
9+
import { generateId } from '@sim/utils/id'
10+
import postgres from 'postgres'
11+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
12+
import {
13+
type ChangelogChangeInput,
14+
insertReleaseInTx,
15+
updateRelease,
16+
} from '@/lib/changelog/repository'
17+
18+
const control = postgres(readTestDatabaseUrl(), { max: 2, onnotice: () => {} })
19+
const userId = generateId()
20+
const workspaceId = generateId()
21+
const workflowId = generateId()
22+
let patch = 0
23+
24+
async function seedDeployment(version: number): Promise<string> {
25+
const id = generateId()
26+
await control`INSERT INTO workflow_deployment_version (id, workflow_id, version, state)
27+
VALUES (${id}, ${workflowId}, ${version}, ${control.json({})})`
28+
return id
29+
}
30+
31+
/** Publishes a release; with `holdOpen`, signals `written` and waits before committing. */
32+
async function publish(
33+
changes: ChangelogChangeInput[],
34+
holdOpen?: { written: () => void; commit: Promise<void> }
35+
) {
36+
const bodyFileId = generateId()
37+
await control`INSERT INTO workspace_files (id, key, user_id, workspace_id, context, original_name, content_type, size_bytes)
38+
VALUES (${bodyFileId}, ${`workspace/${workspaceId}/${bodyFileId}`}, ${userId}, ${workspaceId}, 'changelog',
39+
${`${bodyFileId}.md`}, 'text/markdown', 1)`
40+
patch += 1
41+
return db.transaction(async (tx) => {
42+
const row = await insertReleaseInTx(
43+
tx,
44+
{
45+
id: generateId(),
46+
workspaceId,
47+
title: 'Fixture',
48+
versionMajor: 1,
49+
versionMinor: 0,
50+
versionPatch: patch,
51+
bumpReason: 'fixture',
52+
bodyFileId,
53+
},
54+
changes
55+
)
56+
if (holdOpen) {
57+
holdOpen.written()
58+
await holdOpen.commit
59+
}
60+
return row
61+
})
62+
}
63+
64+
/** Resolves once some session is blocked on an advisory lock. */
65+
async function lockWaiterAppears(): Promise<void> {
66+
for (;;) {
67+
const waiting =
68+
await control`SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted`
69+
if (waiting.length > 0) return
70+
await sleep(10)
71+
}
72+
}
73+
74+
const change = (deploymentVersionId: string): ChangelogChangeInput => ({
75+
text: 'fixture change',
76+
workflowId,
77+
deploymentVersionId,
78+
})
79+
80+
describe('A deployment ships in one release', () => {
81+
beforeAll(async () => {
82+
await control`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
83+
VALUES (${userId}, 'Changelog fixture', ${`${userId}@example.test`}, true, now(), now())`
84+
await control`INSERT INTO workspace (id, name, owner_id, billed_account_user_id)
85+
VALUES (${workspaceId}, 'Changelog fixtures', ${userId}, ${userId})`
86+
await control`INSERT INTO workflow (id, user_id, workspace_id, name, last_synced, created_at, updated_at)
87+
VALUES (${workflowId}, ${userId}, ${workspaceId}, 'Fixture workflow', now(), now(), now())`
88+
})
89+
90+
afterAll(async () => {
91+
await control`DELETE FROM changelog_release WHERE workspace_id = ${workspaceId}`
92+
await control`DELETE FROM workspace WHERE id = ${workspaceId}`
93+
await control`DELETE FROM "user" WHERE id = ${userId}`
94+
await control.end()
95+
})
96+
97+
it('rejects a second release claiming a released deployment', async () => {
98+
const deployment = await seedDeployment(1)
99+
await publish([change(deployment)])
100+
101+
await expect(publish([change(deployment)])).rejects.toThrow(/Already released/)
102+
})
103+
104+
it('lets exactly one of two concurrent publishes claim a deployment', async () => {
105+
const deployment = await seedDeployment(2)
106+
const written = createDeferred<void>()
107+
const commit = createDeferred<void>()
108+
109+
const first = publish([change(deployment)], {
110+
written: () => written.resolve(),
111+
commit: commit.promise,
112+
})
113+
await written.promise
114+
const second = publish([change(deployment)])
115+
await Promise.race([second.catch(() => {}), lockWaiterAppears()])
116+
commit.resolve()
117+
118+
const results = await Promise.allSettled([first, second])
119+
120+
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
121+
const claims =
122+
await control`SELECT 1 FROM changelog_change WHERE deployment_version_id = ${deployment}`
123+
expect(claims).toHaveLength(1)
124+
})
125+
126+
it('lets a release keep its own deployment when its changes are replaced', async () => {
127+
const deployment = await seedDeployment(3)
128+
const release = await publish([change(deployment)])
129+
130+
const updated = await updateRelease(release.id, release.revision, userId, {}, [
131+
{ ...change(deployment), text: 'reworded change' },
132+
])
133+
134+
expect(updated?.changes.map((c) => c.text)).toEqual(['reworded change'])
135+
})
136+
})

‎apps/sim/lib/changelog/repository.ts‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {
77
workflowDeploymentVersion,
88
} from '@sim/db/schema'
99
import { generateId } from '@sim/utils/id'
10-
import { and, asc, desc, eq, inArray, lt, or, sql } from 'drizzle-orm'
11-
import type { ReleaseVersion } from '@/lib/changelog/version'
10+
import { and, asc, desc, eq, inArray, lt, ne, or, sql } from 'drizzle-orm'
11+
import { formatReleaseVersion, type ReleaseVersion } from '@/lib/changelog/version'
12+
import { OrchestrationError } from '@/lib/core/orchestration/types'
13+
import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
1214
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
1315

1416
export type ChangelogReleaseRow = typeof changelogRelease.$inferSelect
@@ -63,16 +65,57 @@ export async function insertReleaseInTx(
6365
changes: ChangelogChangeInput[]
6466
): Promise<ChangelogReleaseRow> {
6567
const [row] = await tx.insert(changelogRelease).values(values).returning()
66-
await insertChanges(tx, row.id, changes)
68+
await insertChanges(tx, row.workspaceId, row.id, changes)
6769
return row
6870
}
6971

72+
/**
73+
* A deployment ships in at most one release. The workspace lock serializes change writes, so two
74+
* concurrent publishes cannot both claim it.
75+
*/
76+
async function assertDeploymentsUnreleased(
77+
tx: DbTransaction,
78+
workspaceId: string,
79+
releaseId: string,
80+
changes: ChangelogChangeInput[]
81+
): Promise<void> {
82+
const deploymentIds = [
83+
...new Set(changes.flatMap((c) => (c.deploymentVersionId ? [c.deploymentVersionId] : []))),
84+
]
85+
if (deploymentIds.length === 0) return
86+
await acquireAdvisoryXactLock(tx, 'changelog_release', `changelog_release:${workspaceId}`)
87+
const released = await tx
88+
.selectDistinct({
89+
deploymentVersionId: changelogChange.deploymentVersionId,
90+
major: changelogRelease.versionMajor,
91+
minor: changelogRelease.versionMinor,
92+
patch: changelogRelease.versionPatch,
93+
})
94+
.from(changelogChange)
95+
.innerJoin(changelogRelease, eq(changelogRelease.id, changelogChange.releaseId))
96+
.where(
97+
and(
98+
inArray(changelogChange.deploymentVersionId, deploymentIds),
99+
ne(changelogChange.releaseId, releaseId)
100+
)
101+
)
102+
if (released.length > 0)
103+
throw new OrchestrationError(
104+
'conflict',
105+
`Already released: ${released
106+
.map((r) => `${r.deploymentVersionId} in ${formatReleaseVersion(r)}`)
107+
.join(', ')}. A deployment ships in one release.`
108+
)
109+
}
110+
70111
async function insertChanges(
71112
tx: DbTransaction,
113+
workspaceId: string,
72114
releaseId: string,
73115
changes: ChangelogChangeInput[]
74116
): Promise<void> {
75117
if (changes.length === 0) return
118+
await assertDeploymentsUnreleased(tx, workspaceId, releaseId, changes)
76119
await tx.insert(changelogChange).values(
77120
changes.map((change, position) => ({
78121
id: generateId(),
@@ -212,7 +255,7 @@ export async function updateRelease(
212255
if (!row) return null
213256
if (changes) {
214257
await tx.delete(changelogChange).where(eq(changelogChange.releaseId, releaseId))
215-
await insertChanges(tx, releaseId, changes)
258+
await insertChanges(tx, row.workspaceId, releaseId, changes)
216259
}
217260
const changesByRelease = await listChangesByRelease([releaseId], tx)
218261
return { row, changes: changesByRelease.get(releaseId) ?? [] }

0 commit comments

Comments
 (0)