From 551d47938066fb50b726f75e0703ed239415c832 Mon Sep 17 00:00:00 2001 From: Gregory Boch Date: Fri, 30 Oct 2026 12:51:42 +0100 Subject: [PATCH 1/5] fix(create-db-worker): sweep every page of stale projects The daily cleanup read only the first 1,000 projects, so stale projects on later pages were never deleted. Follow the API's pagination cursor until there are no more pages. The 24 h threshold is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/delete-stale-workflow.ts | 57 ++++++++---- .../test/delete-stale-workflow.test.ts | 91 +++++++++++++++++++ 2 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 apps/create-db-worker/test/delete-stale-workflow.test.ts diff --git a/apps/create-db-worker/src/delete-stale-workflow.ts b/apps/create-db-worker/src/delete-stale-workflow.ts index 58d2dd5..c46facc 100644 --- a/apps/create-db-worker/src/delete-stale-workflow.ts +++ b/apps/create-db-worker/src/delete-stale-workflow.ts @@ -20,35 +20,54 @@ type ProjectsResponse = { }; }; +const PROJECTS_PAGE_SIZE = 1000; +const STALE_AFTER_MS = 24 * 60 * 60 * 1000; + export class DeleteStaleProjectsWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep): Promise { - const res = await step.do('fetch-projects', async () => { - const response = await fetch('https://api.prisma.io/v1/projects?limit=1000', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.env.INTEGRATION_TOKEN}`, - }, - }); + const projects: Project[] = []; + let cursor: string | null = null; + let page = 0; - if (!response.ok) { - throw new Error(`Failed to fetch projects: ${response.statusText}`); - } + do { + const pageCursor: string | null = cursor; + const res: string = await step.do(`fetch-projects-${page}`, async () => { + const url = new URL('https://api.prisma.io/v1/projects'); + url.searchParams.set('limit', String(PROJECTS_PAGE_SIZE)); + if (pageCursor) { + url.searchParams.set('cursor', pageCursor); + } - const data = await response.text(); - return data; - }); + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.env.INTEGRATION_TOKEN}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch projects: ${response.statusText}`); + } + + const data = await response.text(); + return data; + }); + + const body: ProjectsResponse = JSON.parse(res); + projects.push(...body.data); + cursor = body.pagination.hasMore ? body.pagination.nextCursor : null; + page += 1; + } while (cursor); - const projects: ProjectsResponse = JSON.parse(res); const now = Date.now(); - const twentyFourHours = 24 * 60 * 60 * 1000; - const staleProjects = projects.data.filter((project) => { + const staleProjects = projects.filter((project) => { const createdAt = new Date(project.createdAt).getTime(); - return now - createdAt > twentyFourHours; + return now - createdAt > STALE_AFTER_MS; }); - console.log(`Total projects: ${projects.data.length}, Stale projects: ${staleProjects.length}`); + console.log(`Total projects: ${projects.length}, Stale projects: ${staleProjects.length}`); for (const project of staleProjects) { await step.do(`delete-project-${project.id}`, async () => { diff --git a/apps/create-db-worker/test/delete-stale-workflow.test.ts b/apps/create-db-worker/test/delete-stale-workflow.test.ts new file mode 100644 index 0000000..99f109b --- /dev/null +++ b/apps/create-db-worker/test/delete-stale-workflow.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DeleteStaleProjectsWorkflow } from '../src/delete-stale-workflow'; + +const HOUR_MS = 60 * 60 * 1000; + +function hoursAgo(hours: number) { + return new Date(Date.now() - hours * HOUR_MS).toISOString(); +} + +function page(data: { id: string; createdAt: string }[], nextCursor: string | null) { + return new Response( + JSON.stringify({ + data: data.map((project) => ({ ...project, name: project.id })), + pagination: { hasMore: nextCursor !== null, nextCursor }, + }), + ); +} + +async function runWorkflow() { + const stepNames: string[] = []; + const step = { + do: async (name: string, callback: () => Promise) => { + stepNames.push(name); + return callback(); + }, + }; + // workerd only constructs a WorkflowEntrypoint itself, so run the method on a stand-in `this` + await DeleteStaleProjectsWorkflow.prototype.run.call( + { env: { INTEGRATION_TOKEN: 'test-token' } } as any, + {} as any, + step as any, + ); + return stepNames; +} + +describe('DeleteStaleProjectsWorkflow', () => { + let fetchMock: ReturnType; + + function requestsTo(method: string) { + return fetchMock.mock.calls.filter(([, init]) => init?.method === method).map(([url]) => new URL(url)); + } + + beforeEach(() => { + fetchMock = vi.fn(); + vi.spyOn(globalThis, 'fetch').mockImplementation(fetchMock); + }); + + it('walks every page and deletes stale projects from all of them', async () => { + fetchMock + .mockResolvedValueOnce( + page( + [ + { id: 'stale-1', createdAt: hoursAgo(30) }, + { id: 'fresh-1', createdAt: hoursAgo(2) }, + ], + 'fresh-1', + ), + ) + .mockResolvedValueOnce(page([{ id: 'stale-2', createdAt: hoursAgo(25) }], null)) + .mockImplementation(async () => new Response(null, { status: 200 })); + + const stepNames = await runWorkflow(); + + const lists = requestsTo('GET'); + expect(lists).toHaveLength(2); + expect(lists[0].searchParams.get('cursor')).toBeNull(); + expect(lists[1].searchParams.get('cursor')).toBe('fresh-1'); + + expect(requestsTo('DELETE').map((url) => url.pathname)).toEqual([ + '/v1/projects/stale-1', + '/v1/projects/stale-2', + ]); + + expect(new Set(stepNames).size).toBe(stepNames.length); + }); + + it('stops after one page when there are no more projects', async () => { + fetchMock.mockResolvedValueOnce(page([{ id: 'fresh-1', createdAt: hoursAgo(1) }], null)); + + await runWorkflow(); + + expect(requestsTo('GET')).toHaveLength(1); + expect(requestsTo('DELETE')).toHaveLength(0); + }); + + it('fails when a page cannot be fetched', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500, statusText: 'Server Error' })); + + await expect(runWorkflow()).rejects.toThrow('Failed to fetch projects'); + }); +}); From ee6aaaeb494a02f3a79fc43b7dba9511c79b64d8 Mon Sep 17 00:00:00 2001 From: Gregory Boch Date: Fri, 30 Oct 2026 12:51:43 +0100 Subject: [PATCH 2/5] fix(ci): fail the release job when a publish fails A failed publish bumped the patch version, retried, and swallowed a second failure, so the job stayed green while nothing reached npm. Merges that don't bump the version also published an unreviewed patch that was never committed. Publish the version in package.json, skip it if it is already on npm, and let any publish error fail the job. A new step checks that every package's version is on npm before the job can pass. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 36 +++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32113f6..9f99620 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,22 +73,46 @@ jobs: echo "Using POSTHOG_API_HOST=${POSTHOG_API_HOST}" for pkg in ${{ env.WORKSPACES }}; do - echo "Publishing $pkg to npm..." cd "${{ env.PACKAGES_DIR }}/$pkg" export POSTHOG_API_HOST="${POSTHOG_API_HOST}" export POSTHOG_API_KEY="${POSTHOG_API_KEY}" export CREATE_DB_WORKER_URL="${CREATE_DB_WORKER_URL}" export CLAIM_DB_WORKER_URL="${CLAIM_DB_WORKER_URL}" - # First try to publish - if ! pnpm publish --access public --no-git-checks; then - echo "Publish failed, trying to bump version and retry..." - npm version patch --no-git-tag-version - pnpm publish --access public --no-git-checks || echo "Publish failed again for $pkg" + version=$(node -p "require('./package.json').version") + + # Merges that don't bump the version have nothing to publish + if npm view "$pkg@$version" version >/dev/null 2>&1; then + echo "$pkg@$version is already on npm, skipping." + else + echo "Publishing $pkg@$version to npm..." + pnpm publish --access public --no-git-checks fi cd - >/dev/null done + - name: ✅ Verify Published Versions + run: | + for pkg in ${{ env.WORKSPACES }}; do + version=$(node -p "require('./${{ env.PACKAGES_DIR }}/$pkg/package.json').version") + + # The registry can take a few seconds to serve a new version + published="" + for attempt in 1 2 3 4 5 6; do + published=$(npm view "$pkg@$version" version 2>/dev/null || true) + if [ "$published" = "$version" ]; then + break + fi + sleep 10 + done + + if [ "$published" != "$version" ]; then + echo "::error::$pkg@$version is not on npm" + exit 1 + fi + echo "$pkg@$version is on npm." + done + - name: 📝 Ensure Changeset Exists run: | if [ -z "$(ls -A .changeset 2>/dev/null)" ]; then From 425ae016e2e044556b9218c00977abb6cbdd0bab Mon Sep 17 00:00:00 2001 From: Gregory Boch Date: Tue, 3 Nov 2026 10:24:10 +0100 Subject: [PATCH 3/5] fix(create-db-worker): stop the sweep on a cursor that does not advance If the Projects API ever reports more pages without a new cursor, fail the run instead of fetching the same page again or stopping early. Co-Authored-By: Claude Opus 5.5 --- .../src/delete-stale-workflow.ts | 11 ++++++++- .../test/delete-stale-workflow.test.ts | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/create-db-worker/src/delete-stale-workflow.ts b/apps/create-db-worker/src/delete-stale-workflow.ts index c46facc..0bc830f 100644 --- a/apps/create-db-worker/src/delete-stale-workflow.ts +++ b/apps/create-db-worker/src/delete-stale-workflow.ts @@ -26,6 +26,7 @@ const STALE_AFTER_MS = 24 * 60 * 60 * 1000; export class DeleteStaleProjectsWorkflow extends WorkflowEntrypoint { async run(event: WorkflowEvent, step: WorkflowStep): Promise { const projects: Project[] = []; + const seenCursors = new Set(); let cursor: string | null = null; let page = 0; @@ -56,7 +57,15 @@ export class DeleteStaleProjectsWorkflow extends WorkflowEntrypoint const body: ProjectsResponse = JSON.parse(res); projects.push(...body.data); - cursor = body.pagination.hasMore ? body.pagination.nextCursor : null; + + const { hasMore, nextCursor } = body.pagination; + if (hasMore && (!nextCursor || seenCursors.has(nextCursor))) { + throw new Error(`Projects API reported more pages without a new cursor after page ${page}`); + } + cursor = hasMore ? nextCursor : null; + if (cursor) { + seenCursors.add(cursor); + } page += 1; } while (cursor); diff --git a/apps/create-db-worker/test/delete-stale-workflow.test.ts b/apps/create-db-worker/test/delete-stale-workflow.test.ts index 99f109b..930f166 100644 --- a/apps/create-db-worker/test/delete-stale-workflow.test.ts +++ b/apps/create-db-worker/test/delete-stale-workflow.test.ts @@ -83,6 +83,29 @@ describe('DeleteStaleProjectsWorkflow', () => { expect(requestsTo('DELETE')).toHaveLength(0); }); + it('fails when the API reports more pages without advancing the cursor', async () => { + fetchMock + .mockResolvedValueOnce(page([{ id: 'stale-1', createdAt: hoursAgo(30) }], 'stale-1')) + .mockResolvedValueOnce(page([{ id: 'stale-1', createdAt: hoursAgo(30) }], 'stale-1')); + + await expect(runWorkflow()).rejects.toThrow('without a new cursor'); + expect(requestsTo('DELETE')).toHaveLength(0); + }); + + it('fails when the API reports more pages with no cursor', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: [{ id: 'stale-1', name: 'stale-1', createdAt: hoursAgo(30) }], + pagination: { hasMore: true, nextCursor: null }, + }), + ), + ); + + await expect(runWorkflow()).rejects.toThrow('without a new cursor'); + expect(requestsTo('DELETE')).toHaveLength(0); + }); + it('fails when a page cannot be fetched', async () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 500, statusText: 'Server Error' })); From 21daa41ec3089322f728f6b1e2f47fa3fd63d761 Mon Sep 17 00:00:00 2001 From: Gregory Boch Date: Tue, 3 Nov 2026 10:24:10 +0100 Subject: [PATCH 4/5] fix(ci): require the three CLIs to share one version before publishing create-pg and create-postgres pin the create-db version they were published with. Bumping only create-db would skip the aliases and leave them on the old CLI, so fail the release job unless all three versions match. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f99620..9367104 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,17 @@ jobs: echo "Using CLAIM_DB_WORKER_URL=${CLAIM_DB_WORKER_URL}" echo "Using POSTHOG_API_HOST=${POSTHOG_API_HOST}" + # create-pg and create-postgres pin the create-db version they were + # published with, so the three CLIs must move to a new version together + versions="" + for pkg in ${{ env.WORKSPACES }}; do + versions="$versions $pkg@$(node -p "require('./${{ env.PACKAGES_DIR }}/$pkg/package.json').version")" + done + if [ "$(echo $versions | tr ' ' '\n' | sed 's/.*@//' | sort -u | wc -l)" -ne 1 ]; then + echo "::error::The CLIs must share one version, got:$versions" + exit 1 + fi + for pkg in ${{ env.WORKSPACES }}; do cd "${{ env.PACKAGES_DIR }}/$pkg" export POSTHOG_API_HOST="${POSTHOG_API_HOST}" From ca1544558099bf5519e4923a9f7b4a89e871ca20 Mon Sep 17 00:00:00 2001 From: Gregory Boch Date: Tue, 3 Nov 2026 10:30:31 +0100 Subject: [PATCH 5/5] chore(changeset): version the three CLIs as a fixed group With `linked`, a changeset for one alias bumps only that package, so the versions drift and the release job now stops on the version check. A `fixed` group bumps create-db, create-pg and create-postgres together. Co-Authored-By: Claude Opus 5.5 --- .changeset/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/config.json b/.changeset/config.json index d8b0b65..6c372f7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,5 +9,5 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": ["create-db-worker", "claim-db-worker"], - "linked": [["create-db", "create-pg", "create-postgres"]] + "fixed": [["create-db", "create-pg", "create-postgres"]] }