From be637bf71dec05087299e62774dfbb65efb1a249 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 22:19:33 +0200 Subject: [PATCH 1/2] fix(ci): one-off docs-site state migration to the branch-id scope (TML-3157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs deploy fails on v0.6.0's empty-scope guard: its state sits under the pre-0.6.0 dev_${USER} scope. This performs the remedy the guard prescribes — re-stage the composer-docs rows to the default Branch's id in that Branch's prisma-composer-state database — as an idempotent step before the deploy. Remove the step and script once the deploy is green. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/deploy-docs.yml | 2 + scripts/migrate-docs-state-tml3157.ts | 96 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 scripts/migrate-docs-state-tml3157.ts diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 11ca15a2..5e457bc5 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -72,6 +72,8 @@ jobs: - name: Reset the production project (destroy-then-redeploy cutover) if: github.event_name == 'workflow_dispatch' && inputs.reset run: bun scripts/delete-project.ts composer-docs + - name: One-off TML-3157 state-scope migration (remove once the deploy is green) + run: bun scripts/migrate-docs-state-tml3157.ts # No --stage: this targets production. The CLI is invoked directly rather # than through the package's `deploy` script, which sources a local .env # that does not exist on a runner (the credentials are already in env). diff --git a/scripts/migrate-docs-state-tml3157.ts b/scripts/migrate-docs-state-tml3157.ts new file mode 100644 index 00000000..76fbe581 --- /dev/null +++ b/scripts/migrate-docs-state-tml3157.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun +/** + * ONE-OFF migration for the docs site's deploy state (TML-3157). Remove this + * script and its workflow step once the docs deploy is green. + * + * v0.6.0 scopes deploy state by the target Branch's id; the docs site's + * existing state sits under the pre-0.6.0 scope (Alchemy's dev_${USER} + * default), so the empty-scope guard stops the deploy. This performs the + * remedy the guard's message prescribes: re-stage the docs stack's rows to + * the default Branch's id, in that Branch's prisma-composer-state database. + * + * Idempotent: if the branch-id scope already holds the stack's rows, exits 0 + * without touching anything, so re-runs of the workflow stay green. + */ +import { createManagementApiClient } from '@prisma/management-api-sdk'; +import postgres from 'postgres'; + +const PROJECT_NAME = 'composer-docs'; +const STACK = 'composer-docs'; +const STATE_DATABASE_NAME = 'prisma-composer-state'; + +function fail(message: string): never { + console.error(message); + process.exit(1); +} + +const token = process.env['PRISMA_SERVICE_TOKEN']; +const workspaceId = process.env['PRISMA_WORKSPACE_ID']; +if (!token || !workspaceId) fail('PRISMA_SERVICE_TOKEN and PRISMA_WORKSPACE_ID must be set'); + +const client = createManagementApiClient({ token }); + +const projects = await client.GET('/v1/projects', { params: { query: { workspaceId } } }); +if (projects.error) fail(`listing projects failed: ${JSON.stringify(projects.error)}`); +const project = projects.data.data.find((p) => p.name === PROJECT_NAME); +if (!project) fail(`no project named "${PROJECT_NAME}" in this workspace`); + +const branches = await client.GET('/v1/projects/{projectId}/branches', { + params: { path: { projectId: project.id } }, +}); +if (branches.error) fail(`listing branches failed: ${JSON.stringify(branches.error)}`); +const defaultBranch = branches.data.data.find((b) => b.isDefault); +if (!defaultBranch) fail(`project ${project.id} has no default Branch`); +const newStage = defaultBranch.id; + +const databases = await client.GET('/v1/databases', { + params: { query: { projectId: project.id, branchId: defaultBranch.id } }, +}); +if (databases.error) fail(`listing databases failed: ${JSON.stringify(databases.error)}`); +const stateDb = databases.data.data.find((d) => d.name === STATE_DATABASE_NAME); +if (!stateDb) fail(`no database named "${STATE_DATABASE_NAME}" on branch ${defaultBranch.id}`); + +const created = await client.POST('/v1/databases/{databaseId}/connections', { + params: { path: { databaseId: stateDb.id } }, + body: { name: `tml3157-migration-${Date.now()}` }, +}); +if (created.error) fail(`creating a connection failed: ${JSON.stringify(created.error)}`); +const connectionId = created.data.data.id; +const dsn = created.data.data.endpoints.direct?.connectionString; +if (!dsn) fail('connection has no direct connection string'); + +const sql = postgres(dsn, { max: 1, onnotice: () => {} }); +try { + const scopes = await sql` + SELECT stack, stage, count(*)::int AS rows FROM ( + SELECT stack, stage FROM alchemy_resource_state + UNION ALL + SELECT stack, stage FROM alchemy_stack_output + ) AS both_tables GROUP BY stack, stage ORDER BY stack, stage`; + console.log('scopes present:', JSON.stringify(scopes)); + + const stackScopes = scopes.filter((r) => r.stack === STACK); + if (stackScopes.some((r) => r.stage === newStage)) { + console.log(`scope "${newStage}" already holds "${STACK}" rows — nothing to migrate`); + } else if (stackScopes.length === 0) { + console.log(`no "${STACK}" rows in either table — nothing to migrate`); + } else if (stackScopes.length > 1) { + fail( + `multiple legacy scopes for "${STACK}": ${stackScopes.map((r) => r.stage).join(', ')} — ` + + 'refusing to guess; clean up manually per the deploy guard message.', + ); + } else { + const oldStage = stackScopes[0]?.stage as string; + await sql.begin(async (tx) => { + const a = await tx`UPDATE alchemy_resource_state SET stage = ${newStage} WHERE stack = ${STACK} AND stage = ${oldStage}`; + const b = await tx`UPDATE alchemy_stack_output SET stage = ${newStage} WHERE stack = ${STACK} AND stage = ${oldStage}`; + console.log( + `migrated "${STACK}" from scope "${oldStage}" to "${newStage}": ` + + `${a.count} resource row(s), ${b.count} output row(s)`, + ); + }); + } +} finally { + await sql.end(); + await client.DELETE('/v1/connections/{id}', { params: { path: { id: connectionId } } }); +} From 79403ff0c00a24faeaeeb145a342cec1dd44178a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 3 Aug 2026 22:21:11 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20lint=20=E2=80=94=20no=20bare=20c?= =?UTF-8?q?ast,=20biome=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: willbot Signed-off-by: Will Madden --- scripts/migrate-docs-state-tml3157.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/migrate-docs-state-tml3157.ts b/scripts/migrate-docs-state-tml3157.ts index 76fbe581..04886f36 100644 --- a/scripts/migrate-docs-state-tml3157.ts +++ b/scripts/migrate-docs-state-tml3157.ts @@ -80,10 +80,13 @@ try { 'refusing to guess; clean up manually per the deploy guard message.', ); } else { - const oldStage = stackScopes[0]?.stage as string; + const oldStage = stackScopes[0]?.stage; + if (typeof oldStage !== 'string') fail('unreachable: single legacy scope has no stage value'); await sql.begin(async (tx) => { - const a = await tx`UPDATE alchemy_resource_state SET stage = ${newStage} WHERE stack = ${STACK} AND stage = ${oldStage}`; - const b = await tx`UPDATE alchemy_stack_output SET stage = ${newStage} WHERE stack = ${STACK} AND stage = ${oldStage}`; + const a = + await tx`UPDATE alchemy_resource_state SET stage = ${newStage} WHERE stack = ${STACK} AND stage = ${oldStage}`; + const b = + await tx`UPDATE alchemy_stack_output SET stage = ${newStage} WHERE stack = ${STACK} AND stage = ${oldStage}`; console.log( `migrated "${STACK}" from scope "${oldStage}" to "${newStage}": ` + `${a.count} resource row(s), ${b.count} output row(s)`,