From b54ef15b5ddd43ad8f986a93447a0adbb3e1ac8b Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 18:49:13 +0200 Subject: [PATCH 01/10] feat(prisma-cloud): deploy state speaks the platform Alchemy state API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the hosted state layer around alchemy's stock HTTP state client pointed at /v1/projects/{p}/branches/{b}/alchemy-state, with a server-side deploy lease acquired around the run (heartbeat every 20s on a forked fiber; released as a finalizer; 409 contention fails fast naming the holder). The migration guard survives re-pointed: an empty API scope with live Compute apps on the branch refuses loudly — the stage predates the platform state API. Deletes the interim machinery it replaces: the per-stage prisma-composer-state database bootstrap/ownership/deletion, the SQL store and schema, the Postgres session advisory lock and its liveness checker, the destroy teardown that deleted the state database, and the self-spawned Postgres test harness. The postgres dependency is gone from every package. Bumps @prisma/management-api-sdk to ^1.57.0 for the typed lease routes. New tests drive the REAL stock client against an in-process fake of the state API wire contract. Signed-off-by: willbot Signed-off-by: Will Madden --- .../0-lowering/lowering/package.json | 5 +- .../0-lowering/lowering/src/client.ts | 12 +- .../0-lowering/lowering/src/exports/state.ts | 4 - .../src/state/__tests__/bootstrap.test.ts | 234 ------------ .../src/state/__tests__/delete.test.ts | 184 --------- .../src/state/__tests__/empty-scope.test.ts | 95 +---- .../state/__tests__/fake-management-api.ts | 211 +---------- .../src/state/__tests__/fake-state-api.ts | 351 +++++++++++++++++ .../lowering/src/state/__tests__/harness.ts | 134 ------- .../lowering/src/state/__tests__/lock.test.ts | 147 ------- .../src/state/__tests__/ownership.test.ts | 107 ------ .../src/state/__tests__/service.test.ts | 156 -------- .../src/state/__tests__/state-api.test.ts | 358 ++++++++++++++++++ .../src/state/__tests__/state.test.ts | 222 ----------- .../src/state/__tests__/transient.test.ts | 101 ----- .../lowering/src/state/bootstrap.ts | 269 ------------- .../0-lowering/lowering/src/state/delete.ts | 63 --- .../lowering/src/state/discovery.ts | 104 ----- .../lowering/src/state/empty-scope.ts | 88 ++--- .../0-lowering/lowering/src/state/errors.ts | 19 +- .../0-lowering/lowering/src/state/layer.ts | 128 ++++--- .../0-lowering/lowering/src/state/lease.ts | 152 ++++++++ .../0-lowering/lowering/src/state/lock.ts | 133 ------- .../0-lowering/lowering/src/state/schema.ts | 58 --- .../0-lowering/lowering/src/state/service.ts | 262 ------------- .../lowering/src/state/transient.ts | 62 --- .../target/src/__tests__/teardown.test.ts | 171 --------- .../target/src/control/extension.ts | 8 +- .../1-extensions/target/src/teardown.ts | 78 ---- .../composer-prisma-cloud/package.json | 3 +- packages/9-public/composer/package.json | 3 +- pnpm-lock.yaml | 19 +- 32 files changed, 1018 insertions(+), 2923 deletions(-) delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/delete.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/harness.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/lock.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/ownership.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/service.test.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/transient.test.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/delete.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts create mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/lock.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/schema.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts delete mode 100644 packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts delete mode 100644 packages/1-prisma-cloud/1-extensions/target/src/__tests__/teardown.test.ts delete mode 100644 packages/1-prisma-cloud/1-extensions/target/src/teardown.ts diff --git a/packages/1-prisma-cloud/0-lowering/lowering/package.json b/packages/1-prisma-cloud/0-lowering/lowering/package.json index aa9b050e9..5888d790c 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/package.json +++ b/packages/1-prisma-cloud/0-lowering/lowering/package.json @@ -19,10 +19,9 @@ "dependencies": { "@internal/core": "workspace:0.6.0", "@internal/foundation": "workspace:0.6.0", - "@prisma/management-api-sdk": "^1.50.0", + "@prisma/management-api-sdk": "^1.57.0", "alchemy": "2.0.0-beta.67", - "effect": "4.0.0-beta.103", - "postgres": "^3.4.9" + "effect": "4.0.0-beta.103" }, "devDependencies": { "@internal/tsdown-config": "workspace:0.6.0", diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts index ec3a54eff..57488a29b 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts @@ -7,6 +7,9 @@ import { PrismaCredentials } from './credentials.ts'; export type ManagementApiClient = ReturnType; +/** The origin every Management API call targets — also the origin the hosted Alchemy state API lives under. */ +export const MANAGEMENT_API_ORIGIN = 'https://api.prisma.io'; + /** * The typed Prisma Management API client, built once from the resolved * credentials. Providers yield this in their outer Effect and call it inside @@ -16,11 +19,16 @@ export class ManagementClient extends Context.Service => +export const layer = (options?: { + readonly apiOrigin?: string; +}): Layer.Layer => Layer.effect( ManagementClient, Effect.gen(function* () { const { token } = yield* PrismaCredentials; - return createManagementApiClient({ token: Redacted.value(token) }); + return createManagementApiClient({ + token: Redacted.value(token), + baseUrl: options?.apiOrigin ?? MANAGEMENT_API_ORIGIN, + }); }), ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/state.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/state.ts index 45ab65815..bfbfd1f05 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/exports/state.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/exports/state.ts @@ -1,5 +1 @@ -export { type OwnershipVerifier, verifyOwnership } from '../state/bootstrap.ts'; -export { deleteStateDatabase, deleteStateDatabaseWith } from '../state/delete.ts'; export { prismaStateLayer } from '../state/layer.ts'; -export { migratePrismaState } from '../state/schema.ts'; -export { makePrismaStateService } from '../state/service.ts'; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts deleted file mode 100644 index 92d0b66a1..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/bootstrap.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; -import { ManagementClient } from '../../client.ts'; -import type { ResolvedContainer } from '../../container.ts'; -import { - bootstrapStateConnection, - bootstrapStateConnectionWith, - type OwnershipVerifier, -} from '../bootstrap.ts'; -import { - DEFAULT_BRANCH_ID, - type FakeState, - fakeClient, - newFakeState, - PROJECT_ID, - stateDatabase, - verifierFor, - withDefaultBranch, -} from './fake-management-api.ts'; - -const neverCalled = (): OwnershipVerifier => () => { - throw new Error('verifyOwnership must not be called on the create path — nothing to verify yet'); -}; - -const run = ( - state: FakeState, - verify: OwnershipVerifier, - container: ResolvedContainer = { projectId: PROJECT_ID }, -) => - Effect.runPromise( - bootstrapStateConnectionWith(container, verify).pipe( - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -describe('bootstrapStateConnection', () => { - let state: FakeState; - - beforeEach(() => { - state = newFakeState(); - }); - - test('production, with no branch given, uses the project’s default branch', async () => { - withDefaultBranch(state); - - await run(state, neverCalled()); - - expect(state.branchListCalls).toBe(1); - expect(state.databases[0]?.branchId).toBe(DEFAULT_BRANCH_ID); - }); - - test('production with a carried defaultBranchId uses it without re-resolving the default branch', async () => { - const result = await run(state, neverCalled(), { - projectId: PROJECT_ID, - defaultBranchId: DEFAULT_BRANCH_ID, - }); - - expect(state.branchListCalls).toBe(0); - expect(result.branchId).toBe(DEFAULT_BRANCH_ID); - expect(state.databases[0]?.branchId).toBe(DEFAULT_BRANCH_ID); - }); - - test('a named stage uses the branch it was given, without looking any up', async () => { - state.databases.push(stateDatabase('db-existing', 'br-named')); - - const result = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } }), { - projectId: PROJECT_ID, - branchId: 'br-named', - }); - - expect(state.branchListCalls).toBe(0); - expect(result.branchId).toBe('br-named'); - expect(result.databaseId).toBe('db-existing'); - }); - - test('a project with no default branch fails, naming the project, and creates nothing', async () => { - state.branches[PROJECT_ID] = []; - - await expect(run(state, neverCalled())).rejects.toThrow( - new RegExp(`${PROJECT_ID}.*no default Branch`), - ); - expect(state.databaseCreateCalls).toBe(0); - }); - - test('with no state database present, one is made on the stage’s branch and its ownership is never questioned', async () => { - withDefaultBranch(state); - - const result = await run(state, neverCalled()); - - expect(result.databaseId).toBe('db-1'); - expect(state.databaseCreateCalls).toBe(1); - expect(state.databases[0]?.name).toBe('prisma-composer-state'); - }); - - test('the branch’s own default database is left alone even when it shares our name', async () => { - withDefaultBranch(state); - state.databases.push({ - id: 'db-users-default', - name: 'prisma-composer-state', - isDefault: true, - createdAt: new Date(1).toISOString(), - branchId: DEFAULT_BRANCH_ID, - projectId: PROJECT_ID, - }); - - const result = await run(state, neverCalled()); - - expect(result.databaseId).not.toBe('db-users-default'); - expect(state.databaseCreateCalls).toBe(1); - }); - - test('a database carrying our marker is adopted', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-existing')); - - const result = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } })); - - expect(result.databaseId).toBe('db-existing'); - expect(state.databaseCreateCalls).toBe(0); - }); - - test('a database holding our tables but no marker yet is adopted', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-legacy')); - - const result = await run(state, verifierFor({ 'db-legacy': { kind: 'legacy' } })); - - expect(result.databaseId).toBe('db-legacy'); - expect(state.databaseCreateCalls).toBe(0); - }); - - test('an empty database, left by a run that died before migrating, is adopted', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-empty')); - - const result = await run(state, verifierFor({ 'db-empty': { kind: 'empty' } })); - - expect(result.databaseId).toBe('db-empty'); - expect(state.databaseCreateCalls).toBe(0); - }); - - test('a database holding someone else’s data fails the deploy, naming it, and no second one is made beside it', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-squatter')); - - await expect( - run(state, verifierFor({ 'db-squatter': { kind: 'squatter', tables: ['users', 'orders'] } })), - ).rejects.toThrow(/db-squatter/); - expect(state.databaseCreateCalls).toBe(0); - }); - - test('candidates are tried oldest first, stopping at the first that proves ours', async () => { - withDefaultBranch(state); - state.databases.push( - stateDatabase('db-newest', DEFAULT_BRANCH_ID, 3), - stateDatabase('db-oldest-squatter', DEFAULT_BRANCH_ID, 1), - stateDatabase('db-middle-ours', DEFAULT_BRANCH_ID, 2), - ); - - const calls: string[] = []; - const result = await run( - state, - verifierFor( - { - 'db-oldest-squatter': { kind: 'squatter', tables: ['users'] }, - 'db-middle-ours': { kind: 'ours' }, - // 'db-newest' deliberately unstubbed: the loop must never reach it. - }, - calls, - ), - ); - - expect(result.databaseId).toBe('db-middle-ours'); - expect(calls).toEqual(['postgres://fake/db-oldest-squatter', 'postgres://fake/db-middle-ours']); - }); - - test('a failure creating the database surfaces rather than being swallowed', async () => { - withDefaultBranch(state); - state.createShouldFail = true; - - await expect(run(state, neverCalled())).rejects.toThrow(); - }); - - test('the connection string comes from the direct endpoint', async () => { - withDefaultBranch(state); - - const result = await run(state, neverCalled()); - - expect(Redacted.value(result.connectionString)).toBe(`postgres://fake/${result.databaseId}`); - expect(state.connectionCalls).toEqual([result.databaseId]); - }); - - test('our own connections older than 24h are cleaned up; fresh and foreign ones are left', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-existing')); - - const now = Date.now(); - const dayMs = 24 * 60 * 60 * 1000; - state.connections['db-existing'] = [ - { - id: 'conn-aged', - name: 'prisma-composer-state-1', - createdAt: new Date(now - 2 * dayMs).toISOString(), - }, - { id: 'conn-fresh', name: 'prisma-composer-state-2', createdAt: new Date(now).toISOString() }, - { - id: 'conn-foreign', - name: 'someone-elses-connection', - createdAt: new Date(now - 2 * dayMs).toISOString(), - }, - ]; - - await run(state, verifierFor({ 'db-existing': { kind: 'ours' } })); - - expect(state.deletedConnectionIds).toEqual(['conn-aged']); - }); - - test('a connection cleanup that finds nothing still lets the deploy through', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-existing')); - const result = await run(state, verifierFor({ 'db-existing': { kind: 'ours' } })); - - expect(result.databaseId).toBe('db-existing'); - }); -}); - -describe('bootstrapStateConnection (public entry point)', () => { - test('wires the real verifyOwnership — typechecked here, not run (that would touch a real Postgres)', () => { - const typed: (container: ResolvedContainer) => ReturnType = - bootstrapStateConnection; - expect(typed).toBe(bootstrapStateConnection); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/delete.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/delete.test.ts deleted file mode 100644 index 7431c0ca7..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/delete.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import * as Effect from 'effect/Effect'; -import { ManagementClient } from '../../client.ts'; -import type { ResolvedContainer } from '../../container.ts'; -import type { OwnershipVerifier } from '../bootstrap.ts'; -import { deleteStateDatabase, deleteStateDatabaseWith } from '../delete.ts'; -import { - DEFAULT_BRANCH_ID, - type FakeState, - fakeClient, - newFakeState, - PROJECT_ID, - stateDatabase, - verifierFor, - withDefaultBranch, -} from './fake-management-api.ts'; - -const neverCalled = (): OwnershipVerifier => () => { - throw new Error('verifyOwnership must be consulted before any database is deleted'); -}; - -const run = ( - state: FakeState, - verify: OwnershipVerifier, - container: ResolvedContainer = { projectId: PROJECT_ID }, -) => - Effect.runPromise( - deleteStateDatabaseWith(container, verify).pipe( - Effect.provideService(ManagementClient, fakeClient(state)), - ), - ); - -describe('deleteStateDatabase', () => { - let state: FakeState; - - beforeEach(() => { - state = newFakeState(); - }); - - test('production, with no branch given, looks on the project’s default branch', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-prod')); - - await run(state, verifierFor({ 'db-prod': { kind: 'ours' } })); - - expect(state.branchListCalls).toBe(1); - expect(state.deletedDatabaseIds).toEqual(['db-prod']); - }); - - test('a named stage looks on the branch it was given, without looking any up', async () => { - state.databases.push(stateDatabase('db-stage', 'br-named')); - - await run(state, verifierFor({ 'db-stage': { kind: 'ours' } }), { - projectId: PROJECT_ID, - branchId: 'br-named', - }); - - expect(state.branchListCalls).toBe(0); - expect(state.deletedDatabaseIds).toEqual(['db-stage']); - }); - - test('finding no state database succeeds, so a repeated destroy is a no-op', async () => { - withDefaultBranch(state); - - await run(state, neverCalled()); - - expect(state.deletedDatabaseIds).toEqual([]); - }); - - test('a database carrying our marker is deleted', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-ours')); - - await run(state, verifierFor({ 'db-ours': { kind: 'ours' } })); - - expect(state.deletedDatabaseIds).toEqual(['db-ours']); - }); - - test('a database holding our tables but no marker yet is deleted', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-legacy')); - - await run(state, verifierFor({ 'db-legacy': { kind: 'legacy' } })); - - expect(state.deletedDatabaseIds).toEqual(['db-legacy']); - }); - - test('an empty database, left by a run that died before migrating, is deleted', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-empty')); - - await run(state, verifierFor({ 'db-empty': { kind: 'empty' } })); - - expect(state.deletedDatabaseIds).toEqual(['db-empty']); - }); - - test('a database holding someone else’s data is left alone, and the destroy still succeeds', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-squatter')); - - await run(state, verifierFor({ 'db-squatter': { kind: 'squatter', tables: ['users'] } })); - - expect(state.deletedDatabaseIds).toEqual([]); - }); - - test('the branch’s own default database is never deleted, even when it shares our name', async () => { - withDefaultBranch(state); - state.databases.push({ - id: 'db-users-default', - name: 'prisma-composer-state', - isDefault: true, - createdAt: new Date(1).toISOString(), - branchId: DEFAULT_BRANCH_ID, - projectId: PROJECT_ID, - }); - - await run(state, neverCalled()); - - expect(state.deletedDatabaseIds).toEqual([]); - }); - - test('every database we own is deleted, so duplicates left by a crashed run all go', async () => { - withDefaultBranch(state); - state.databases.push( - stateDatabase('db-first', DEFAULT_BRANCH_ID, 1), - stateDatabase('db-second', DEFAULT_BRANCH_ID, 2), - ); - - await run(state, verifierFor({ 'db-first': { kind: 'ours' }, 'db-second': { kind: 'empty' } })); - - expect(state.deletedDatabaseIds).toEqual(['db-first', 'db-second']); - }); - - test('someone else’s database does not stop ours from being deleted', async () => { - withDefaultBranch(state); - state.databases.push( - stateDatabase('db-squatter', DEFAULT_BRANCH_ID, 1), - stateDatabase('db-ours', DEFAULT_BRANCH_ID, 2), - ); - - await run( - state, - verifierFor({ - 'db-squatter': { kind: 'squatter', tables: ['users'] }, - 'db-ours': { kind: 'ours' }, - }), - ); - - expect(state.deletedDatabaseIds).toEqual(['db-ours']); - }); - - test('a database already gone counts as deleted, so a retried destroy still completes', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-ours')); - state.deleteShouldFailWith = 404; - - await expect(run(state, verifierFor({ 'db-ours': { kind: 'ours' } }))).resolves.toBeUndefined(); - }); - - test('a deletion the platform refuses fails the destroy', async () => { - withDefaultBranch(state); - state.databases.push(stateDatabase('db-ours')); - state.deleteShouldFailWith = 409; - - await expect(run(state, verifierFor({ 'db-ours': { kind: 'ours' } }))).rejects.toThrow(); - }); - - test('a project with no default branch fails, naming the project, and deletes nothing', async () => { - state.branches[PROJECT_ID] = []; - - await expect(run(state, neverCalled())).rejects.toThrow( - new RegExp(`${PROJECT_ID}.*no default Branch`), - ); - expect(state.deletedDatabaseIds).toEqual([]); - }); -}); - -describe('deleteStateDatabase (public entry point)', () => { - test('wires the real verifyOwnership — typechecked here, not run (that would touch a real Postgres)', () => { - const typed: (container: ResolvedContainer) => ReturnType = - deleteStateDatabase; - expect(typed).toBe(deleteStateDatabase); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts index 6d0ac3042..f9311d46b 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts @@ -1,80 +1,9 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import * as Effect from 'effect/Effect'; -import postgres from 'postgres'; import { ManagementClient } from '../../client.ts'; import { PrismaApiError } from '../../http.ts'; -import { failOnEmptyScopeWithLiveApps, scopeOccupied } from '../empty-scope.ts'; -import { migratePrismaState } from '../schema.ts'; +import { failOnEmptyScopeWithLiveApps } from '../empty-scope.ts'; import { fakeClient, newFakeState, PROJECT_ID } from './fake-management-api.ts'; -import { startTestPostgres, type TestPostgres } from './harness.ts'; - -const pg: TestPostgres | undefined = startTestPostgres(); - -if (pg === undefined) { - console.warn( - '[alchemy/state] skipping empty-scope occupancy tests: no Postgres available. ' + - 'Set STATE_TEST_DATABASE_URL to point at one, or install initdb/pg_ctl ' + - '(e.g. `brew install postgresql@15`) on PATH.', - ); -} - -describe.skipIf(pg === undefined)('scopeOccupied', () => { - if (pg === undefined) return; - - const sql = postgres(pg.url, { max: 5, onnotice: () => {} }); - const stack = 'demo-stack'; - const stage = 'br_test123'; - - const occupied = () => Effect.runPromise(scopeOccupied(sql, stack, stage)); - - beforeAll(async () => { - await Effect.runPromise(migratePrismaState(sql)); - }); - - afterAll(async () => { - await sql.end({ timeout: 1 }); - pg.stop(); - }); - - beforeEach(async () => { - await sql`truncate table alchemy_resource_state, alchemy_stack_output`; - }); - - test('an empty database is unoccupied', async () => { - expect(await occupied()).toBe(false); - }); - - test('a resource row under the scope makes it occupied', async () => { - await sql` - insert into alchemy_resource_state (stack, stage, fqn, value) - values (${stack}, ${stage}, 'app/db', ${sql.json({ fqn: 'app/db' })}) - `; - - expect(await occupied()).toBe(true); - }); - - test('an output row alone under the scope makes it occupied', async () => { - await sql` - insert into alchemy_stack_output (stack, stage, value) - values (${stack}, ${stage}, ${sql.json({ url: 'https://example.test' })}) - `; - - expect(await occupied()).toBe(true); - }); - - test("other scopes' and other stacks' rows don't count", async () => { - await sql` - insert into alchemy_resource_state (stack, stage, fqn, value) - values (${stack}, 'dev_alice', 'app/db', ${sql.json({ fqn: 'app/db' })}) - `; - await sql` - insert into alchemy_resource_state (stack, stage, fqn, value) - values ('other-stack', ${stage}, 'app/db', ${sql.json({ fqn: 'app/db' })}) - `; - - expect(await occupied()).toBe(false); - }); -}); describe('failOnEmptyScopeWithLiveApps', () => { const branchId = 'br-default'; @@ -104,14 +33,14 @@ describe('failOnEmptyScopeWithLiveApps', () => { expect(error).toBeInstanceOf(PrismaApiError); const message = (error as PrismaApiError).message; - expect(message).toContain(`deploy state scope "${stage}" is empty`); + expect(message).toContain(`no deploy state for stage "${stage}"`); expect(message).toContain(branchId); expect(message).toContain('"storefront.web"'); expect(message).toContain('"storefront.worker"'); expect(message).toContain('already_exists'); }); - test('the message tells a pre-branch-id deployment how to migrate manually, and a foreign one to move aside', async () => { + test('the message says the stage predates the platform state API and how to cut over', async () => { const state = newFakeState({ apps: [{ id: 'app-1', name: 'storefront.web', projectId: PROJECT_ID, branchId }], }); @@ -119,20 +48,10 @@ describe('failOnEmptyScopeWithLiveApps', () => { const error: unknown = await check(state).catch((e: unknown) => e); const message = (error as PrismaApiError).message; - expect(message).toContain( - 'UPDATE the stage column of alchemy_resource_state and alchemy_stack_output', - ); - // Single quotes: the fragment must be valid SQL when pasted — double - // quotes are Postgres identifier quotes and would error. - expect(message).toContain(`to '${stage}'`); - // The UPDATE must be stack-filtered — the state database can hold other - // stacks' rows, which an unfiltered UPDATE would rewrite too. - expect(message).toContain(`WHERE stack = '${stack}'`); - expect(message).toContain('delete the apps in the Prisma Console'); + expect(message).toContain('predates the platform state API'); + expect(message).toContain('previous version of composer'); + expect(message).toContain('delete the stage'); expect(message).toContain('redeploy fresh'); - // The old first remedy was un-followable: destroy builds this same state - // layer and hits this same guard. - expect(message).not.toContain('destroy and redeploy'); expect(message).toContain('remove them or deploy into a different project'); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts index 5ee823ca2..15ec32286 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts @@ -1,28 +1,5 @@ import { blindCast } from '@internal/foundation/casts'; -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; import type { ManagementApiClient } from '../../client.ts'; -import type { OwnershipVerdict, OwnershipVerifier } from '../bootstrap.ts'; - -export interface FakeBranch { - id: string; - isDefault: boolean; -} - -export interface FakeDatabase { - id: string; - name: string; - isDefault: boolean; - createdAt: string; - branchId: string | null; - projectId: string; -} - -export interface FakeConnection { - id: string; - name: string; - createdAt: string; -} export interface FakeApp { id: string; @@ -32,35 +9,15 @@ export interface FakeApp { } export interface FakeState { - branches: Record; - databases: FakeDatabase[]; - connections: Record; apps: FakeApp[]; /** Page size for GET /v1/apps — unset serves everything in one page. */ appsPageSize?: number; /** When set, GET /v1/apps reports hasMore with a nextCursor equal to the request's cursor — a broken, non-advancing pagination. */ appsCursorStuck?: boolean; - createShouldFail: boolean; - deleteShouldFailWith: number | undefined; - branchListCalls: number; - databaseCreateCalls: number; - connectionCalls: string[]; - deletedDatabaseIds: string[]; - deletedConnectionIds: string[]; } export const newFakeState = (overrides: Partial = {}): FakeState => ({ - branches: {}, - databases: [], - connections: {}, apps: [], - createShouldFail: false, - deleteShouldFailWith: undefined, - branchListCalls: 0, - databaseCreateCalls: 0, - connectionCalls: [], - deletedDatabaseIds: [], - deletedConnectionIds: [], ...overrides, }); @@ -70,62 +27,16 @@ const okResponse = (data: T, status = 200) => ({ response: new Response(null, { status }), }); -const errorResponse = (status: number) => ({ - data: undefined, - error: { message: 'stubbed failure' }, - response: new Response(null, { status }), -}); - type FakeInit = { params?: { path?: Record; query?: Record }; - body?: Record; }; /** * A stubbed `ManagementApiClient` — just enough of the Management API to - * exercise branch resolution, state-database discovery, creation, deletion, - * and connection handling without touching the cloud. - * - * The project-scoped create and any PATCH throw instead of answering, so - * attaching the database with a second call from our side fails loudly here. - * - * The flat create is modelled as a single step that lands the database on the - * given Branch. The real platform creates the row on the default Branch and - * attaches it afterwards, so no test here can reach the failed-attach outcome — - * that risk is accepted, not impossible. + * exercise the empty-scope guard's app listing without touching the cloud. */ export const fakeClient = (state: FakeState): ManagementApiClient => { const GET = (path: string, init: FakeInit = {}) => { - if (path === '/v1/projects/{projectId}/branches') { - const projectId = init.params?.path?.['projectId'] ?? ''; - state.branchListCalls++; - return Promise.resolve( - okResponse({ - data: state.branches[projectId] ?? [], - pagination: { nextCursor: null, hasMore: false }, - }), - ); - } - if (path === '/v1/databases') { - const query = init.params?.query ?? {}; - const filtered = state.databases.filter( - (d) => - d.branchId === (query['branchId'] ?? null) && - (query['projectId'] === undefined || d.projectId === query['projectId']), - ); - return Promise.resolve( - okResponse({ data: filtered, pagination: { nextCursor: null, hasMore: false } }), - ); - } - if (path === '/v1/databases/{databaseId}/connections') { - const databaseId = init.params?.path?.['databaseId'] ?? ''; - return Promise.resolve( - okResponse({ - data: state.connections[databaseId] ?? [], - pagination: { nextCursor: null, hasMore: false }, - }), - ); - } if (path === '/v1/apps') { const query = init.params?.query ?? {}; const filtered = state.apps.filter( @@ -153,126 +64,10 @@ export const fakeClient = (state: FakeState): ManagementApiClient => { throw new Error(`fakeClient: unexpected GET ${path}`); }; - const POST = (path: string, init: FakeInit = {}) => { - if (path === '/v1/databases') { - state.databaseCreateCalls++; - if (state.createShouldFail) return Promise.resolve(errorResponse(409)); - const id = `db-${state.databaseCreateCalls}`; - const branchId = init.body?.['branchId']; - if (typeof branchId !== 'string') { - throw new Error('fakeClient: a state database must be created with a branchId'); - } - const database: FakeDatabase = { - id, - name: String(init.body?.['name']), - isDefault: false, - createdAt: new Date(state.databaseCreateCalls).toISOString(), - branchId, - projectId: String(init.body?.['projectId']), - }; - state.databases.push(database); - return Promise.resolve(okResponse({ data: database }, 201)); - } - if (path === '/v1/projects/{projectId}/databases') { - throw new Error( - 'fakeClient: the state database must be created via POST /v1/databases with a branchId — ' + - 'the project-scoped endpoint has no branchId field, so the database would be born on ' + - "the default Branch (production's) and only move afterwards.", - ); - } - if (path === '/v1/databases/{databaseId}/connections') { - const databaseId = init.params?.path?.['databaseId'] ?? ''; - state.connectionCalls.push(databaseId); - return Promise.resolve( - okResponse({ - data: { - id: `conn-${databaseId}-${state.connectionCalls.length}`, - endpoints: { - direct: { - host: 'fake', - port: 5432, - connectionString: `postgres://fake/${databaseId}`, - }, - }, - }, - }), - ); - } - throw new Error(`fakeClient: unexpected POST ${path}`); - }; - - const PATCH = (path: string) => { - if (path === '/v1/databases/{databaseId}') { - throw new Error( - 'fakeClient: a state database must be attached to its Branch at creation, never moved ' + - 'onto it by a follow-up PATCH.', - ); - } - throw new Error(`fakeClient: unexpected PATCH ${path}`); - }; - - const DELETE = (path: string, init: FakeInit = {}) => { - if (path === '/v1/databases/{databaseId}') { - const databaseId = init.params?.path?.['databaseId'] ?? ''; - if (state.deleteShouldFailWith !== undefined) { - return Promise.resolve(errorResponse(state.deleteShouldFailWith)); - } - state.deletedDatabaseIds.push(databaseId); - state.databases = state.databases.filter((d) => d.id !== databaseId); - return Promise.resolve(okResponse(undefined, 204)); - } - if (path === '/v1/connections/{id}') { - const id = init.params?.path?.['id'] ?? ''; - state.deletedConnectionIds.push(id); - for (const databaseId of Object.keys(state.connections)) { - const list = state.connections[databaseId]; - if (list === undefined) continue; - state.connections[databaseId] = list.filter((c) => c.id !== id); - } - return Promise.resolve(okResponse(undefined, 204)); - } - throw new Error(`fakeClient: unexpected DELETE ${path}`); - }; - return blindCast< ManagementApiClient, - 'a hand-written fake of openapi-fetch’s generated client: its four methods answer only the paths these suites exercise, and reproducing the real generic signature would add no safety the per-path handlers above do not already give' - >({ GET, POST, PATCH, DELETE }); -}; - -/** A verifier stub that maps each fake database id to a canned verdict, and fails the test if asked about one it wasn't told to expect. */ -export const verifierFor = ( - verdicts: Record, - calls: string[] = [], -): OwnershipVerifier => { - return (connectionString) => { - const dsn = Redacted.value(connectionString); - calls.push(dsn); - const databaseId = dsn.replace('postgres://fake/', ''); - const verdict = verdicts[databaseId]; - if (verdict === undefined) throw new Error(`verifierFor: no verdict stubbed for ${databaseId}`); - return Effect.succeed(verdict); - }; + 'a hand-written fake of openapi-fetch’s generated client: it answers only the paths these suites exercise, and reproducing the real generic signature would add no safety the per-path handlers above do not already give' + >({ GET }); }; export const PROJECT_ID = 'proj-1'; -export const DEFAULT_BRANCH_ID = 'br-default'; - -/** Registers the default Branch every live Project is guaranteed to own. Its absence is its own test scenario. */ -export const withDefaultBranch = (state: FakeState): void => { - state.branches[PROJECT_ID] = [{ id: DEFAULT_BRANCH_ID, isDefault: true }]; -}; - -/** A state database on the given branch, named ours and non-default — a candidate for adoption or deletion. */ -export const stateDatabase = ( - id: string, - branchId: string = DEFAULT_BRANCH_ID, - createdAtMs = 1, -): FakeDatabase => ({ - id, - name: 'prisma-composer-state', - isDefault: false, - createdAt: new Date(createdAtMs).toISOString(), - branchId, - projectId: PROJECT_ID, -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts new file mode 100644 index 000000000..bf3bde8dc --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts @@ -0,0 +1,351 @@ +import * as http from 'node:http'; + +/** + * An in-process fake of the platform Alchemy state API: the alchemy + * `HttpStateApi` wire contract (see node_modules/alchemy/src/state/ + * HttpStateApi.ts) mounted under `/v1/projects/{p}/branches/{b}/alchemy-state`, + * plus the deploy-lease endpoints and the two Management API listings the + * state layer touches (`/v1/apps`, `/v1/projects/{p}/branches`). + * + * Wire fidelity the tests depend on: absent values answer 200 with a JSON + * `null` body (not 204); PUT echoes its payload; DELETE answers 204; the fqn + * path segment arrives double-encoded and the server decodes it once beyond + * transport decoding. State operations and the heartbeat/release enforce the + * lease: a missing or stale `Alchemy-State-Lease-Id` answers 409 (state ops) + * or 404 (lease calls). + */ + +interface Lease { + leaseId: string; + holder: string; + expiresAt: string; +} + +export interface RequestLogEntry { + method: string; + path: string; +} + +export interface FakeApp { + id: string; + name: string; + projectId: string; + branchId: string; +} + +const json = (res: http.ServerResponse, status: number, body: unknown): void => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +}; + +const noContent = (res: http.ServerResponse): void => { + res.writeHead(204); + res.end(); +}; + +const apiError = ( + res: http.ServerResponse, + status: number, + code: string, + message: string, +): void => { + json(res, status, { error: { code, message } }); +}; + +const readBody = (req: http.IncomingMessage): Promise> => + new Promise((resolve) => { + let raw = ''; + req.on('data', (chunk: Buffer) => { + raw += chunk.toString(); + }); + req.on('end', () => { + try { + resolve(raw.length === 0 ? {} : JSON.parse(raw)); + } catch { + resolve({}); + } + }); + }); + +export class FakeStateApi { + readonly requests: RequestLogEntry[] = []; + readonly apps: FakeApp[] = []; + /** (stack \0 stage \0 fqn) → stored state. */ + private readonly resources = new Map(); + /** (stack \0 stage) → stack output. */ + private readonly outputs = new Map(); + /** (branch \0 stack \0 stage) → live lease. */ + private readonly leases = new Map(); + private leaseCounter = 0; + private server: http.Server | undefined; + private originValue = ''; + + get origin(): string { + return this.originValue; + } + + async start(): Promise { + this.server = http.createServer((req, res) => { + void this.handle(req, res); + }); + await new Promise((resolve) => this.server?.listen(0, '127.0.0.1', resolve)); + const address = this.server.address(); + if (address === null || typeof address === 'string') { + throw new Error('fake state API failed to bind a TCP port'); + } + this.originValue = `http://127.0.0.1:${String(address.port)}`; + } + + async stop(): Promise { + await new Promise((resolve, reject) => { + this.server?.close((err) => (err ? reject(err) : resolve())); + }); + } + + /** Clears every lease, stored state, app, and the request log — a fresh server between tests. */ + reset(): void { + this.leases.clear(); + this.resources.clear(); + this.outputs.clear(); + this.apps.length = 0; + this.requests.length = 0; + } + + countRequests(pattern: RegExp): number { + return this.requests.filter((r) => pattern.test(`${r.method} ${r.path}`)).length; + } + + /** Expires every live lease — the next state op or heartbeat sees the lease as lost. */ + revokeAllLeases(): void { + this.leases.clear(); + } + + liveLeaseIds(): string[] { + return [...this.leases.values()].map((l) => l.leaseId); + } + + seedResource(stack: string, stage: string, fqn: string, value: unknown): void { + this.resources.set([stack, stage, fqn].join('\0'), value); + } + + private leaseByHeader(req: http.IncomingMessage): Lease | undefined { + const id = req.headers['alchemy-state-lease-id']; + return [...this.leases.values()].find((l) => l.leaseId === id); + } + + private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const url = new URL(req.url ?? '/', this.originValue); + const method = req.method ?? 'GET'; + this.requests.push({ method, path: url.pathname }); + + // Transport decoding: one decodeURIComponent per raw segment. + const segments = url.pathname + .split('/') + .filter((s) => s.length > 0) + .map((s) => decodeURIComponent(s)); + + if (segments[0] !== 'v1') return apiError(res, 404, 'not_found', 'unknown path'); + + if (segments[1] === 'apps' && method === 'GET') { + const projectId = url.searchParams.get('projectId'); + const branchId = url.searchParams.get('branchId'); + const data = this.apps.filter( + (a) => + (projectId === null || a.projectId === projectId) && + (branchId === null || a.branchId === branchId), + ); + return json(res, 200, { data, pagination: { nextCursor: null, hasMore: false } }); + } + + if ( + segments[1] === 'projects' && + segments[3] === 'branches' && + segments.length === 4 && + method === 'GET' + ) { + return json(res, 200, { + data: [{ id: 'br-default', isDefault: true }], + pagination: { nextCursor: null, hasMore: false }, + }); + } + + if ( + segments[1] !== 'projects' || + segments[3] !== 'branches' || + segments[5] !== 'alchemy-state' + ) { + return apiError(res, 404, 'not_found', 'unknown path'); + } + const branchId = segments[4] ?? ''; + const rest = segments.slice(6); + + if (rest[0] === 'lease') return this.handleLease(req, res, method, branchId); + if (rest[0] === 'version' && method === 'GET') return json(res, 200, { version: 5 }); + if (rest[0] !== 'state' || rest[1] !== 'stacks') { + return apiError(res, 404, 'not_found', 'unknown path'); + } + return this.handleState(req, res, method, branchId, rest.slice(2), url); + } + + private async handleLease( + req: http.IncomingMessage, + res: http.ServerResponse, + method: string, + branchId: string, + ): Promise { + if (method === 'POST') { + const body = await readBody(req); + const stack = String(body['stack'] ?? ''); + const stage = String(body['stage'] ?? ''); + const key = [branchId, stack, stage].join('\0'); + const existing = this.leases.get(key); + if (existing !== undefined) { + return apiError( + res, + 409, + 'lease_held', + `the deploy lease for stage "${stage}" is held by ${existing.holder}`, + ); + } + this.leaseCounter += 1; + const lease: Lease = { + leaseId: `lease-${String(this.leaseCounter)}`, + holder: String(body['holderDescription'] ?? 'unknown'), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + this.leases.set(key, lease); + return json(res, 201, { data: { leaseId: lease.leaseId, expiresAt: lease.expiresAt } }); + } + + const lease = this.leaseByHeader(req); + if (lease === undefined) { + return apiError(res, 404, 'lease_not_found', 'no unexpired lease matches the given id'); + } + if (method === 'PATCH') { + lease.expiresAt = new Date(Date.now() + 60_000).toISOString(); + return json(res, 200, { data: { leaseId: lease.leaseId, expiresAt: lease.expiresAt } }); + } + if (method === 'DELETE') { + for (const [key, value] of this.leases) { + if (value.leaseId === lease.leaseId) this.leases.delete(key); + } + return noContent(res); + } + return apiError(res, 404, 'not_found', 'unknown path'); + } + + private handleState( + req: http.IncomingMessage, + res: http.ServerResponse, + method: string, + branchId: string, + path: readonly string[], + url: URL, + ): Promise | void { + if (this.leaseByHeader(req) === undefined) { + return apiError( + res, + 409, + 'lease_required', + 'every state operation requires a live deploy lease', + ); + } + + // GET /state/stacks + if (path.length === 0 && method === 'GET') { + const stacks = new Set(); + for (const key of [...this.resources.keys(), ...this.outputs.keys()]) { + stacks.add(key.split('\0')[0] ?? ''); + } + return json(res, 200, [...stacks].sort()); + } + + const stack = path[0] ?? ''; + + // DELETE /state/stacks/:stack[?stage=…] + if (path.length === 1 && method === 'DELETE') { + const stage = url.searchParams.get('stage'); + for (const map of [this.resources, this.outputs]) { + for (const key of [...map.keys()]) { + const [s, st] = key.split('\0'); + if (s === stack && (stage === null || st === stage)) map.delete(key); + } + } + return noContent(res); + } + + // GET /state/stacks/:stack/stages + if (path[1] === 'stages' && path.length === 2 && method === 'GET') { + const stages = new Set(); + for (const key of [...this.resources.keys(), ...this.outputs.keys()]) { + const [s, st] = key.split('\0'); + if (s === stack && st !== undefined) stages.add(st); + } + return json(res, 200, [...stages].sort()); + } + + const stage = path[2] ?? ''; + const scopePrefix = `${stack}\0${stage}\0`; + + // …/stages/:stage/resources + if (path[3] === 'resources' && path.length === 4 && method === 'GET') { + const fqns = [...this.resources.keys()] + .filter((key) => key.startsWith(scopePrefix)) + .map((key) => key.slice(scopePrefix.length)) + .sort(); + return json(res, 200, fqns); + } + + // …/stages/:stage/resources/:fqn — the segment is still encoded once + // after transport decoding (the stock client double-encodes): decode once. + if (path[3] === 'resources' && path.length === 5) { + const fqn = decodeURIComponent(path[4] ?? ''); + const key = scopePrefix + fqn; + if (method === 'GET') return json(res, 200, this.resources.get(key) ?? null); + if (method === 'PUT') { + return readBody(req).then((body) => { + this.resources.set(key, body); + return json(res, 200, body); + }); + } + if (method === 'DELETE') { + this.resources.delete(key); + return noContent(res); + } + } + + // …/stages/:stage/replaced-resources + if (path[3] === 'replaced-resources' && method === 'GET') { + const isReplaced = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + 'status' in value && + value.status === 'replaced'; + const replaced = [...this.resources.entries()] + .filter(([key, value]) => key.startsWith(scopePrefix) && isReplaced(value)) + .map(([, value]) => value); + return json(res, 200, replaced); + } + + // …/stages/:stage/output + if (path[3] === 'output') { + const key = `${stack}\0${stage}`; + if (method === 'GET') return json(res, 200, this.outputs.get(key) ?? null); + if (method === 'PUT') { + return readBody(req).then((body) => { + this.outputs.set(key, body); + return json(res, 200, body); + }); + } + } + + return apiError(res, 404, 'not_found', `unknown state path: ${branchId}/${path.join('/')}`); + } +} + +/** Starts a fake and guarantees teardown via the returned stop. */ +export const startFakeStateApi = async (): Promise => { + const fake = new FakeStateApi(); + await fake.start(); + return fake; +}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/harness.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/harness.ts deleted file mode 100644 index c71c47c86..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/harness.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { execFileSync, spawnSync } from 'node:child_process'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -export interface TestPostgres { - readonly url: string; - readonly stop: () => void; -} - -// Some sandboxes leave LANG/LC_* unset or pointed at a locale glibc/ICU -// can't resolve, which makes `postmaster` become multithreaded during -// startup and immediately refuse to serve ("postmaster became -// multithreaded during startup", hint: set LC_ALL). Pin C for every -// initdb/pg_ctl invocation so the cluster starts the same way everywhere. -const PG_ENV = { ...process.env, LC_ALL: 'C', LANG: 'C' }; - -const probe = (bin: string): boolean => - spawnSync(bin, ['--version'], { stdio: 'ignore', env: PG_ENV }).status === 0; - -// Ubuntu's postgresql-common packaging installs versioned server binaries -// under /usr/lib/postgresql//bin, never on PATH and not covered by -// the Homebrew paths above — this is what a plain `ubuntu-latest` CI runner -// has after `apt-get install postgresql`. Glob every installed version -// rather than pinning one. -const globUbuntuPostgresqlBinCandidates = (name: string): string[] => { - const base = '/usr/lib/postgresql'; - try { - return fs.readdirSync(base).map((version) => path.join(base, version, 'bin', name)); - } catch { - return []; - } -}; - -const findBinary = (name: string): string | undefined => { - const candidates = [ - name, - `/opt/homebrew/opt/postgresql@15/bin/${name}`, - `/opt/homebrew/bin/${name}`, - `/usr/local/opt/postgresql@15/bin/${name}`, - `/usr/local/bin/${name}`, - ...globUbuntuPostgresqlBinCandidates(name), - ]; - return candidates.find(probe); -}; - -/** - * Synchronously starts (or reuses) a throwaway Postgres for the state-store - * tests. Runs at module load — before `describe.skipIf` gates the suite — - * because bun collects tests synchronously, so availability must be known - * before the file finishes registering its `describe` blocks. - * - * Resolution order: - * 1. `STATE_TEST_DATABASE_URL` — a pre-existing Postgres (e.g. a CI service - * container). Used as-is; `stop()` is a no-op since this harness didn't - * start it. - * 2. `initdb` + `pg_ctl` on PATH (or common Homebrew locations) — spins an - * ephemeral cluster under `STATE_TEST_PG_TMPDIR` (falls back to the OS - * temp dir) on a random high port, and tears it down in `stop()`. - * - * Returns `undefined` when neither is available and `process.env.CI` is - * unset (a local dev machine without Postgres installed) — callers must skip - * loudly, never silently pass, when this returns `undefined`. On CI, the - * absence of both is a configuration bug, not a skip condition: this throws - * instead, so the state/lock suites can never quietly go unexecuted. - */ -export const startTestPostgres = (): TestPostgres | undefined => { - const fromEnv = process.env['STATE_TEST_DATABASE_URL']; - if (fromEnv !== undefined) { - return { url: fromEnv, stop: () => {} }; - } - - const initdb = findBinary('initdb'); - const pgCtl = findBinary('pg_ctl'); - if (initdb === undefined || pgCtl === undefined) { - if (process.env['CI'] !== undefined) { - // On CI this suite must never silently skip: a green `pnpm test` has - // to mean the store/lock suites actually ran. Throwing here (instead - // of returning undefined, which `describe.skipIf` would swallow) - // fails the build loudly when the CI job forgot to wire a Postgres. - throw new Error( - 'CI is set but no Postgres is available for the state-store tests: neither ' + - 'STATE_TEST_DATABASE_URL nor initdb/pg_ctl (PATH, Homebrew, or Ubuntu ' + - '/usr/lib/postgresql/*/bin) were found. Wire a `services: postgres:` container ' + - 'and STATE_TEST_DATABASE_URL on the CI test job (see .github/workflows/ci.yml).', - ); - } - return undefined; - } - - const baseDir = process.env['STATE_TEST_PG_TMPDIR'] ?? os.tmpdir(); - fs.mkdirSync(baseDir, { recursive: true }); - const dataDir = fs.mkdtempSync(path.join(baseDir, 'prisma-composer-state-pg-')); - const logFile = path.join(dataDir, 'server.log'); - - execFileSync( - initdb, - ['-D', dataDir, '-U', 'postgres', '--auth=trust', '-E', 'UTF8', '--locale=C'], - { - stdio: 'pipe', - env: PG_ENV, - }, - ); - - let lastError = 'unknown error'; - for (let attempt = 0; attempt < 5; attempt++) { - const port = 20000 + Math.floor(Math.random() * 20000); - const result = spawnSync( - pgCtl, - ['-D', dataDir, '-o', `-p ${port} -h 127.0.0.1`, '-w', '-l', logFile, 'start'], - { stdio: 'pipe', env: PG_ENV }, - ); - if (result.status === 0) { - return { - url: `postgres://postgres@127.0.0.1:${port}/postgres`, - stop: () => { - try { - execFileSync(pgCtl, ['-D', dataDir, '-m', 'fast', 'stop'], { - stdio: 'pipe', - env: PG_ENV, - }); - } finally { - fs.rmSync(dataDir, { recursive: true, force: true }); - } - }, - }; - } - lastError = result.stderr.toString(); - } - fs.rmSync(dataDir, { recursive: true, force: true }); - throw new Error( - `initdb/pg_ctl were found on PATH but the ephemeral test Postgres failed to start after 5 attempts: ${lastError}`, - ); -}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/lock.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/lock.test.ts deleted file mode 100644 index c37458c5e..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/lock.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { afterAll, describe, expect, test } from 'bun:test'; -import { assertDefined } from '@internal/foundation/assertions'; -import * as Effect from 'effect/Effect'; -import postgres from 'postgres'; -import { acquireStateLock } from '../lock.ts'; -import { startTestPostgres, type TestPostgres } from './harness.ts'; - -const pg: TestPostgres | undefined = startTestPostgres(); - -if (pg === undefined) { - console.warn( - '[alchemy/state] skipping lock tests: no Postgres available. ' + - 'Set STATE_TEST_DATABASE_URL to point at one, or install initdb/pg_ctl ' + - '(e.g. `brew install postgresql@15`) on PATH.', - ); -} - -describe.skipIf(pg === undefined)('acquireStateLock', () => { - if (pg === undefined) return; - - afterAll(() => pg.stop()); - - const client = () => postgres(pg.url, { max: 5, onnotice: () => {} }); - - test('acquire, contend, release, re-acquire — two real sessions against the same (stack, stage)', async () => { - const stack = 'lock-stack'; - const stage = 'acquire-contend-release'; - const sqlA = client(); - const sqlB = client(); - - const lockA = await Effect.runPromise(acquireStateLock(sqlA, stack, stage)); - - await expect(Effect.runPromise(acquireStateLock(sqlB, stack, stage))).rejects.toThrow( - /another deploy holds the state lock for lock-stack\/acquire-contend-release/, - ); - - await lockA.release(); - - const lockB = await Effect.runPromise(acquireStateLock(sqlB, stack, stage)); - await lockB.release(); - - await sqlA.end({ timeout: 1 }); - await sqlB.end({ timeout: 1 }); - }); - - test('crash-release: a dropped connection (no explicit release) frees the lock for another session', async () => { - const stack = 'lock-stack'; - const stage = 'crash-release'; - const sqlA = client(); - const sqlB = client(); - - await Effect.runPromise(acquireStateLock(sqlA, stack, stage)); - // Simulate the deployer process dying: the connection drops without - // `lock.release()` ever running. Postgres auto-releases the - // session-scoped advisory lock when the session ends. - await sqlA.end({ timeout: 0 }); - - const lockB = await Effect.runPromise(acquireStateLock(sqlB, stack, stage)); - await lockB.release(); - - await sqlB.end({ timeout: 1 }); - }); - - test('lease-loss: once the reserved connection dies mid-run, checkLive fails loudly', async () => { - const stack = 'lock-stack'; - const stage = 'lease-loss'; - const sqlA = client(); - - const lockA = await Effect.runPromise(acquireStateLock(sqlA, stack, stage)); - - // Sanity: the lease is live immediately after acquiring it. - await Effect.runPromise(lockA.checkLive); - - // Kill the reserved connection without calling release() — the lease - // is now lost, and every subsequent state operation must refuse to - // run unlocked. - await sqlA.end({ timeout: 0 }); - - await expect(Effect.runPromise(lockA.checkLive)).rejects.toThrow(); - }); - - // FT-5219's real failure mode is a server-side kill of the reserved lock - // connection (e.g. an idle-connection reaper), not a client-side - // `sql.end()` (the previous test) — `.end()` marks the connection - // `terminated` client-side, which makes postgres.js reject the next query - // cleanly. A server-side `pg_terminate_backend` does not set that flag, - // so this exercises the scenario the design actually depends on: does - // `checkLive` still fail, or does postgres.js silently hand it a - // reconnected session that no longer holds the lock? - test('FT-5219: a server-killed reserved connection (not a client-side .end()) is still caught by checkLive', async () => { - const stack = 'lock-stack'; - const stage = 'server-kill'; - const sqlA = client(); - const admin = client(); - - const lockA = await Effect.runPromise(acquireStateLock(sqlA, stack, stage)); - await Effect.runPromise(lockA.checkLive); - - // Find the reserved connection's backend pid the way an operator would - // — by joining the advisory lock it holds (identified by the same key - // `acquireStateLock` computes) to `pg_stat_activity`, not by reaching - // into `acquireStateLock`'s internals. - const key = `prisma-composer:${stack}/${stage}`; - const lockRows = await admin<{ pid: number }[]>` - select l.pid - from pg_locks l - join pg_stat_activity a on a.pid = l.pid - where l.locktype = 'advisory' - and l.granted - and ((l.classid::bigint << 32) | (l.objid::bigint & 4294967295)) - = hashtextextended(${key}, 0) - `; - expect(lockRows.length).toBe(1); - const lockPid = lockRows[0]?.pid; - assertDefined(lockPid, 'expected to find the reserved connection holding the advisory lock'); - - await admin`select pg_terminate_backend(${lockPid})`; - - // Poll briefly: pg_terminate_backend signals the backend but does not - // block until it has fully exited. - let stillHeld = true; - for (let attempt = 0; attempt < 50 && stillHeld; attempt++) { - const rows = await admin<{ live: boolean }[]>` - select exists ( - select 1 from pg_locks where locktype = 'advisory' and pid = ${lockPid} - ) as live - `; - stillHeld = rows[0]?.live ?? false; - if (stillHeld) await new Promise((resolve) => setTimeout(resolve, 20)); - } - expect(stillHeld).toBe(false); - - // The real assertion: checkLive must fail rather than silently succeed - // against a transparently-reconnected session that no longer holds the - // lock. - await expect(Effect.runPromise(lockA.checkLive)).rejects.toThrow(); - - // And the lock is genuinely free — a second session can now acquire it. - const sqlB = client(); - const lockB = await Effect.runPromise(acquireStateLock(sqlB, stack, stage)); - await lockB.release(); - await sqlB.end({ timeout: 1 }); - - await sqlA.end({ timeout: 1 }); - await admin.end({ timeout: 1 }); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/ownership.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/ownership.test.ts deleted file mode 100644 index d93e0e507..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/ownership.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterAll, describe, expect, test } from 'bun:test'; -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; -import postgres from 'postgres'; -import { verifyOwnership } from '../bootstrap.ts'; -import { migratePrismaState } from '../schema.ts'; -import { startTestPostgres, type TestPostgres } from './harness.ts'; - -const pg: TestPostgres | undefined = startTestPostgres(); - -if (pg === undefined) { - console.warn( - '[alchemy/state] skipping ownership tests: no Postgres available. ' + - 'Set STATE_TEST_DATABASE_URL to point at one, or install initdb/pg_ctl ' + - '(e.g. `brew install postgresql@15`) on PATH.', - ); -} - -// verifyOwnership inspects every table in a database's public schema, so -// each scenario below needs its own fresh database rather than sharing one -// (unlike state.test.ts's truncate-between-tests strategy, which works -// because that suite only ever cares about row contents, never which -// tables exist). -describe.skipIf(pg === undefined)('verifyOwnership', () => { - if (pg === undefined) return; - - const admin = postgres(pg.url, { max: 1, onnotice: () => {} }); - let counter = 0; - - afterAll(async () => { - await admin.end({ timeout: 1 }); - pg.stop(); - }); - - const freshDatabaseUrl = async (): Promise => { - counter++; - const name = `prisma_app_state_ownership_test_${counter}`; - await admin.unsafe(`create database ${name}`); - const url = new URL(pg.url); - url.pathname = `/${name}`; - return url.toString(); - }; - - test('an empty database (no tables at all) verifies as empty', async () => { - const url = await freshDatabaseUrl(); - - const verdict = await Effect.runPromise(verifyOwnership(Redacted.make(url))); - - expect(verdict).toEqual({ kind: 'empty' }); - }); - - test('a database migrated by migratePrismaState verifies as ours', async () => { - const url = await freshDatabaseUrl(); - const sql = postgres(url, { max: 1, onnotice: () => {} }); - await Effect.runPromise(migratePrismaState(sql)); - await sql.end({ timeout: 1 }); - - const verdict = await Effect.runPromise(verifyOwnership(Redacted.make(url))); - - expect(verdict).toEqual({ kind: 'ours' }); - }); - - test('a database with our state tables but no marker (pre-hardening deployment) verifies as legacy', async () => { - const url = await freshDatabaseUrl(); - const sql = postgres(url, { max: 1, onnotice: () => {} }); - await sql` - create table alchemy_resource_state ( - stack text not null, stage text not null, fqn text not null, - value jsonb not null, updated_at timestamptz not null default now(), - primary key (stack, stage, fqn) - ) - `; - await sql.end({ timeout: 1 }); - - const verdict = await Effect.runPromise(verifyOwnership(Redacted.make(url))); - - expect(verdict).toEqual({ kind: 'legacy' }); - }); - - test('a database with foreign tables and no marker verifies as squatter, naming the tables', async () => { - const url = await freshDatabaseUrl(); - const sql = postgres(url, { max: 1, onnotice: () => {} }); - await sql`create table users (id text primary key)`; - await sql.end({ timeout: 1 }); - - const verdict = await Effect.runPromise(verifyOwnership(Redacted.make(url))); - - expect(verdict.kind).toBe('squatter'); - if (verdict.kind === 'squatter') expect(verdict.tables).toContain('users'); - }); - - test('a marker table present without our marker row verifies as squatter (someone else’s marker scheme)', async () => { - const url = await freshDatabaseUrl(); - const sql = postgres(url, { max: 1, onnotice: () => {} }); - await sql` - create table prisma_app_state_meta ( - marker text primary key, created_at timestamptz not null default now() - ) - `; - await sql`insert into prisma_app_state_meta (marker) values ('not-our-marker')`; - await sql.end({ timeout: 1 }); - - const verdict = await Effect.runPromise(verifyOwnership(Redacted.make(url))); - - expect(verdict.kind).toBe('squatter'); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/service.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/service.test.ts deleted file mode 100644 index 135aaa923..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/service.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { type PersistedState, type StateService, StateStoreError } from 'alchemy/State'; -import * as Effect from 'effect/Effect'; -import { guardStateService } from '../service.ts'; - -/** A stub StateService that records every method it was actually invoked with. */ -const makeStubService = (): { readonly service: StateService; readonly calls: string[] } => { - const calls: string[] = []; - const record = (name: string, value: A): Effect.Effect => - Effect.sync(() => { - calls.push(name); - return value; - }); - - const service: StateService = { - id: 'stub', - getVersion: () => record('getVersion', 5), - listStacks: () => record('listStacks', []), - listStages: () => record('listStages', []), - get: () => record('get', undefined), - getReplacedResources: () => record('getReplacedResources', []), - set: (request) => record('set', request.value), - delete: () => record('delete', undefined), - deleteStack: () => record('deleteStack', undefined), - list: () => record('list', []), - getOutput: () => record('getOutput', undefined), - setOutput: (request) => record('setOutput', request.value), - }; - - return { service, calls }; -}; - -const request = { stack: 'stack', stage: 'stage', fqn: 'fqn', value: {} as PersistedState }; - -describe('guardStateService', () => { - test('when checkLive fails, every guarded method fails and the underlying service is never invoked', async () => { - const { service, calls } = makeStubService(); - const checkLive = Effect.fail(new StateStoreError({ message: 'lease lost' })); - const guarded = guardStateService(service, checkLive); - - await expect(Effect.runPromise(guarded.listStacks())).rejects.toThrow(); - await expect(Effect.runPromise(guarded.listStages('stack'))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.get(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.getReplacedResources(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.set(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.delete(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.deleteStack(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.list(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.getOutput(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.setOutput(request))).rejects.toThrow(); - - expect(calls).toEqual([]); - }); - - test('when checkLive passes, every guarded method calls through to the underlying service', async () => { - const { service, calls } = makeStubService(); - const guarded = guardStateService(service, Effect.void); - - await Effect.runPromise(guarded.listStacks()); - await Effect.runPromise(guarded.listStages('stack')); - await Effect.runPromise(guarded.get(request)); - await Effect.runPromise(guarded.getReplacedResources(request)); - await Effect.runPromise(guarded.set(request)); - await Effect.runPromise(guarded.delete(request)); - await Effect.runPromise(guarded.deleteStack(request)); - await Effect.runPromise(guarded.list(request)); - await Effect.runPromise(guarded.getOutput(request)); - await Effect.runPromise(guarded.setOutput(request)); - - expect(calls).toEqual([ - 'listStacks', - 'listStages', - 'get', - 'getReplacedResources', - 'set', - 'delete', - 'deleteStack', - 'list', - 'getOutput', - 'setOutput', - ]); - }); - - test('getVersion is excluded from the guard — it calls through even when checkLive fails', async () => { - const { service, calls } = makeStubService(); - const checkLive = Effect.fail(new StateStoreError({ message: 'lease lost' })); - const guarded = guardStateService(service, checkLive); - - const version = await Effect.runPromise(guarded.getVersion()); - - expect(version).toBe(5); - expect(calls).toEqual(['getVersion']); - }); - - test('id passes through unguarded', () => { - const { service } = makeStubService(); - const guarded = guardStateService(service, Effect.void); - - expect(guarded.id).toBe(service.id); - }); - - // A checkLive that counts its runs and can be flipped to fail, plus a - // clock the test drives. Amortization is keyed on wall time, so a fixed - // clock means "within the TTL window". - const countingCheck = () => { - let count = 0; - let fail = false; - const checkLive = Effect.suspend(() => { - count += 1; - return fail ? Effect.fail(new StateStoreError({ message: 'lease lost' })) : Effect.void; - }); - return { - checkLive, - runs: () => count, - startFailing: () => { - fail = true; - }, - }; - }; - - test('amortizes the lease check: a burst of operations inside the TTL window does one round-trip', async () => { - const { service } = makeStubService(); - const check = countingCheck(); - const guarded = guardStateService(service, check.checkLive, () => 1_000); // clock frozen inside the window - - for (let i = 0; i < 5; i += 1) await Effect.runPromise(guarded.get(request)); - - expect(check.runs()).toBe(1); - }); - - test('re-checks after the TTL expires — a lease lost after the window is detected', async () => { - const { service } = makeStubService(); - const check = countingCheck(); - let clock = 1_000; - const guarded = guardStateService(service, check.checkLive, () => clock); - - await Effect.runPromise(guarded.get(request)); // passes, caches - check.startFailing(); - await Effect.runPromise(guarded.get(request)); // still inside TTL — trusted, succeeds - clock += 6_000; // advance past the 5s TTL - await expect(Effect.runPromise(guarded.get(request))).rejects.toThrow(); // re-checks, now fails - expect(check.runs()).toBe(2); // first success + the post-expiry failure - }); - - test('a failing check is never cached — the next op re-checks immediately', async () => { - const { service } = makeStubService(); - const check = countingCheck(); - check.startFailing(); - const guarded = guardStateService(service, check.checkLive, () => 1_000); // frozen clock - - await expect(Effect.runPromise(guarded.get(request))).rejects.toThrow(); - await expect(Effect.runPromise(guarded.get(request))).rejects.toThrow(); - - expect(check.runs()).toBe(2); // no cached success to skip on - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts new file mode 100644 index 000000000..758bd5926 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts @@ -0,0 +1,358 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { createManagementApiClient } from '@prisma/management-api-sdk'; +import { Stack } from 'alchemy'; +import { + type CreatedResourceState, + makeHttpStateStore, + State, + type StateService, +} from 'alchemy/State'; +import * as Effect from 'effect/Effect'; +import * as Fiber from 'effect/Fiber'; +import * as Layer from 'effect/Layer'; +import * as Redacted from 'effect/Redacted'; +import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient'; +import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; +import { stateLayerAgainst } from '../layer.ts'; +import { + acquireDeployLease, + type DeployLease, + heartbeatDeployLease, + LEASE_HEADER, + type LeaseScope, + releaseDeployLease, +} from '../lease.ts'; +import { FakeStateApi } from './fake-state-api.ts'; + +process.env['PRISMA_SERVICE_TOKEN'] = 'test-service-token'; + +const PROJECT_ID = 'proj-1'; +const BRANCH_ID = 'br-1'; +const STACK = 'demo-stack'; +const STAGE = 'br_test123'; + +const scope: LeaseScope = { + projectId: PROJECT_ID, + branchId: BRANCH_ID, + stack: STACK, + stage: STAGE, +}; + +let fake: FakeStateApi; + +beforeAll(async () => { + fake = new FakeStateApi(); + await fake.start(); +}); + +afterAll(async () => { + await fake.stop(); +}); + +beforeEach(() => { + fake.reset(); +}); + +const sdkClient = () => + createManagementApiClient({ token: 'test-service-token', baseUrl: fake.origin }); + +/** The REAL stock alchemy client, pointed at the fake, carrying the lease header. */ +const buildStore = (lease: DeployLease): Promise => + Effect.runPromise( + makeHttpStateStore({ + url: `${fake.origin}/v1/projects/${PROJECT_ID}/branches/${BRANCH_ID}/alchemy-state`, + authToken: 'test-service-token', + transformClient: (req) => + HttpClientRequest.setHeader(req, LEASE_HEADER, Redacted.value(lease.leaseId)), + id: 'prisma-postgres', + }).pipe(Effect.provide(FetchHttpClient.layer)), + ); + +const acquire = () => Effect.runPromise(acquireDeployLease(sdkClient(), scope)); + +const createdResource = (overrides: Partial = {}): CreatedResourceState => ({ + resourceType: 'Test.Resource', + namespace: undefined, + fqn: 'test/resource', + logicalId: 'resource', + instanceId: 'instance-1', + providerVersion: 1, + status: 'created', + downstream: [], + bindings: [], + props: {}, + attr: {}, + ...overrides, +}); + +describe('the stock state client against the platform state API', () => { + test('all core methods round-trip a resource and a stack output', async () => { + const service = await buildStore(await acquire()); + const run = (eff: Effect.Effect) => + Effect.runPromise(eff as Effect.Effect); + + expect(await run(service.listStacks())).toEqual([]); + + const value = createdResource({ fqn: 'app/db' }); + expect(await run(service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value }))).toEqual( + value, + ); + + expect(await run(service.listStacks())).toEqual([STACK]); + expect(await run(service.listStages(STACK))).toEqual([STAGE]); + expect(await run(service.list({ stack: STACK, stage: STAGE }))).toEqual([value.fqn]); + expect(await run(service.get({ stack: STACK, stage: STAGE, fqn: value.fqn }))).toEqual(value); + + const outputValue = { url: 'https://example.test' }; + expect( + await run(service.setOutput({ stack: STACK, stage: STAGE, value: outputValue })), + ).toEqual(outputValue); + expect(await run(service.getOutput({ stack: STACK, stage: STAGE }))).toEqual(outputValue); + + await run(service.delete({ stack: STACK, stage: STAGE, fqn: value.fqn })); + expect(await run(service.get({ stack: STACK, stage: STAGE, fqn: value.fqn }))).toBeUndefined(); + + await run(service.setOutput({ stack: STACK, stage: STAGE, value: outputValue })); + await run(service.deleteStack({ stack: STACK, stage: STAGE })); + expect(await run(service.getOutput({ stack: STACK, stage: STAGE }))).toBeUndefined(); + expect(await run(service.listStacks())).toEqual([]); + }); + + test('an absent resource reads as undefined (the wire answers 200 with JSON null)', async () => { + const service = await buildStore(await acquire()); + + const absent = await Effect.runPromise( + service.get({ stack: STACK, stage: STAGE, fqn: 'does/not-exist' }), + ); + + expect(absent).toBeUndefined(); + }); + + test('a slash-bearing fqn round-trips — the client double-encodes, the server decodes once', async () => { + const service = await buildStore(await acquire()); + const value = createdResource({ fqn: 'nested/name with spaces/%odd' }); + + await Effect.runPromise(service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value })); + + expect(await Effect.runPromise(service.list({ stack: STACK, stage: STAGE }))).toEqual([ + value.fqn, + ]); + expect( + await Effect.runPromise(service.get({ stack: STACK, stage: STAGE, fqn: value.fqn })), + ).toEqual(value); + }); + + test('Redacted values round-trip byte-identically', async () => { + const service = await buildStore(await acquire()); + const value = createdResource({ + fqn: 'app/secret', + props: { token: Redacted.make('sk-live-abc123') }, + }); + + await Effect.runPromise(service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value })); + const revived = await Effect.runPromise( + service.get({ stack: STACK, stage: STAGE, fqn: value.fqn }), + ); + + const props = (revived as CreatedResourceState | undefined)?.props; + expect(Redacted.isRedacted(props?.['token'])).toBe(true); + expect(Redacted.value(props?.['token'])).toBe('sk-live-abc123'); + }); + + test('getReplacedResources returns only replaced-status states', async () => { + const service = await buildStore(await acquire()); + const created = createdResource({ fqn: 'app/created' }); + await Effect.runPromise( + service.set({ stack: STACK, stage: STAGE, fqn: created.fqn, value: created }), + ); + + expect( + await Effect.runPromise(service.getReplacedResources({ stack: STACK, stage: STAGE })), + ).toEqual([]); + }); + + test('losing the lease mid-run fails the next operation WITHOUT retries — exactly one request', async () => { + const service = await buildStore(await acquire()); + const value = createdResource({ fqn: 'app/db' }); + await Effect.runPromise(service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value })); + + fake.revokeAllLeases(); + fake.requests.length = 0; + + const result = await Effect.runPromise( + service.get({ stack: STACK, stage: STAGE, fqn: value.fqn }).pipe(Effect.flip), + ); + + expect(result._tag).toBe('StateStoreError'); + expect(result.message).toContain('409'); + expect(fake.countRequests(/GET .*\/resources\//)).toBe(1); + }); +}); + +describe('the deploy lease', () => { + test('a second acquire for the same (stack, stage) fails fast naming the holder — no retry, no queueing', async () => { + await acquire(); + fake.requests.length = 0; + + const error = await Effect.runPromise(acquireDeployLease(sdkClient(), scope).pipe(Effect.flip)); + + expect(error.status).toBe(409); + expect(error.message).toContain('is held by'); + expect(fake.countRequests(/POST .*\/lease/)).toBe(1); + }); + + test('release frees the lease so the next deploy can acquire it', async () => { + const lease = await acquire(); + + await Effect.runPromise(releaseDeployLease(sdkClient(), scope, lease)); + + expect(fake.liveLeaseIds()).toEqual([]); + await expect(acquire()).resolves.toBeDefined(); + }); + + test('release after the lease already expired does not fail the run', async () => { + const lease = await acquire(); + fake.revokeAllLeases(); + + await expect( + Effect.runPromise(releaseDeployLease(sdkClient(), scope, lease)), + ).resolves.toBeUndefined(); + }); + + test('the heartbeat extends the lease on schedule', async () => { + const lease = await acquire(); + + await Effect.runPromise( + Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + heartbeatDeployLease(sdkClient(), scope, lease, '10 millis'), + ); + yield* Effect.sleep('100 millis'); + yield* Fiber.interrupt(fiber); + }), + ); + + expect(fake.countRequests(/PATCH .*\/lease/)).toBeGreaterThanOrEqual(2); + }); + + test('a heartbeat 404 (lease lost) stops the heartbeat without failing the run', async () => { + const lease = await acquire(); + fake.revokeAllLeases(); + + await expect( + Effect.runPromise( + Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + heartbeatDeployLease(sdkClient(), scope, lease, '10 millis'), + ); + // Joining succeeds only because the 404 ENDS the loop; a failing + // heartbeat would reject this promise. + yield* Fiber.join(fiber); + }), + ), + ).resolves.toBeUndefined(); + + const patchesAfterStop = fake.countRequests(/PATCH .*\/lease/); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fake.countRequests(/PATCH .*\/lease/)).toBe(patchesAfterStop); + }); +}); + +describe('prismaStateLayer against the platform state API', () => { + const stackContext = Layer.succeed(Stack, { + name: STACK, + stage: STAGE, + resources: {}, + bindings: {}, + actions: {}, + }); + + const runLayer = ( + use: (service: StateService) => Effect.Effect, + ids: { projectId: string; branchId?: string; defaultBranchId?: string } = { + projectId: PROJECT_ID, + branchId: BRANCH_ID, + }, + ): Promise => { + const layer = stateLayerAgainst(fake.origin, ids).pipe( + Layer.provide(stackContext), + ) as unknown as Layer.Layer; + return Effect.runPromise( + Effect.gen(function* () { + const service = yield* yield* State; + return yield* use(service).pipe(Effect.orDie); + }).pipe(Effect.provide(layer)) as Effect.Effect, + ); + }; + + test('layer init acquires the lease, serves state through the stock client, and releases on exit', async () => { + const value = createdResource({ fqn: 'app/db' }); + + const fetched = await runLayer((service) => + Effect.gen(function* () { + yield* service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value }); + return yield* service.get({ stack: STACK, stage: STAGE, fqn: value.fqn }); + }), + ); + + expect(fetched).toEqual(value); + // The scope's finalizer released the lease. + expect(fake.liveLeaseIds()).toEqual([]); + }); + + test('a concurrent second deploy of the same stage fails immediately, naming the holder', async () => { + // A live deploy holds the lease, acquired as another operator. + const holderResponse = await fetch( + `${fake.origin}/v1/projects/${PROJECT_ID}/branches/${BRANCH_ID}/alchemy-state/lease`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ stack: STACK, stage: STAGE, holderDescription: 'alice@laptop' }), + }, + ); + expect(holderResponse.status).toBe(201); + fake.requests.length = 0; + + const error: unknown = await runLayer(() => Effect.void).catch((e: unknown) => e); + + expect(String(error)).toContain('acquiring the deploy lease'); + expect(String(error)).toContain('alice@laptop'); + // Fail-fast: the 409 was never retried. + expect(fake.countRequests(/POST .*\/lease/)).toBe(1); + }); + + test('an empty scope with live apps on the branch refuses — the stage predates the platform state API', async () => { + fake.apps.push({ id: 'app-1', name: 'legacy.web', projectId: PROJECT_ID, branchId: BRANCH_ID }); + + const error: unknown = await runLayer(() => Effect.void).catch((e: unknown) => e); + + expect(String(error)).toContain('predates the platform state API'); + expect(String(error)).toContain('"legacy.web"'); + // The refusal released the lease on the way out. + expect(fake.liveLeaseIds()).toEqual([]); + }); + + test('an empty scope with NO apps proceeds — a genuinely fresh stage deploys', async () => { + await expect(runLayer(() => Effect.void)).resolves.toBeUndefined(); + }); + + test('a non-empty scope proceeds even with live apps — a normal redeploy', async () => { + fake.apps.push({ id: 'app-1', name: 'live.web', projectId: PROJECT_ID, branchId: BRANCH_ID }); + fake.seedResource(STACK, STAGE, 'app/db', createdResource({ fqn: 'app/db' })); + + await expect(runLayer(() => Effect.void)).resolves.toBeUndefined(); + }); + + test('with no branch id, the default branch is resolved via the Management API', async () => { + const value = createdResource({ fqn: 'app/default-branch' }); + + await expect( + runLayer((service) => service.set({ stack: STACK, stage: STAGE, fqn: value.fqn, value }), { + projectId: PROJECT_ID, + }), + ).resolves.toEqual(value); + + // The write landed under the resolved default branch, not a literal. + expect(fake.countRequests(/PUT .*br-default.*\/resources\//)).toBe(1); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state.test.ts deleted file mode 100644 index 676db390b..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; -import type { CreatedResourceState, ReplacedResourceState } from 'alchemy/State'; -import * as Duration from 'effect/Duration'; -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; -import postgres from 'postgres'; -import { makePrismaStateService, migratePrismaState } from '../../exports/state.ts'; -import { startTestPostgres, type TestPostgres } from './harness.ts'; - -const pg: TestPostgres | undefined = startTestPostgres(); - -if (pg === undefined) { - console.warn( - '[alchemy/state] skipping state store tests: no Postgres available. ' + - 'Set STATE_TEST_DATABASE_URL to point at one, or install initdb/pg_ctl ' + - '(e.g. `brew install postgresql@15`) on PATH.', - ); -} - -const createdResource = (overrides: Partial = {}): CreatedResourceState => ({ - resourceType: 'Test.Resource', - namespace: undefined, - fqn: 'test/resource', - logicalId: 'resource', - instanceId: 'instance-1', - providerVersion: 1, - status: 'created', - downstream: [], - bindings: [], - props: {}, - attr: {}, - ...overrides, -}); - -const replacedResource = ( - overrides: Partial = {}, -): ReplacedResourceState => ({ - resourceType: 'Test.Resource', - namespace: undefined, - fqn: 'test/replaced', - logicalId: 'replaced', - instanceId: 'instance-2', - providerVersion: 1, - status: 'replaced', - downstream: [], - bindings: [], - props: {}, - attr: {}, - old: createdResource({ fqn: 'test/replaced-old' }), - deleteFirst: false, - ...overrides, -}); - -describe.skipIf(pg === undefined)('makePrismaStateService', () => { - if (pg === undefined) return; - - // The migration idempotence test intentionally re-runs `create table if not - // exists`, which Postgres reports via NOTICE — silence those so test output - // isn't dominated by expected, harmless noise. - const sql = postgres(pg.url, { max: 5, onnotice: () => {} }); - const service = makePrismaStateService(sql); - const stack = 'test-stack'; - const stage = 'test-stage'; - - beforeAll(async () => { - await Effect.runPromise(migratePrismaState(sql)); - }); - - afterAll(async () => { - await sql.end({ timeout: 1 }); - pg.stop(); - }); - - beforeEach(async () => { - await sql`truncate table alchemy_resource_state, alchemy_stack_output`; - }); - - test('id identifies this store', () => { - expect(service.id).toBe('prisma-postgres'); - }); - - test('getVersion returns the alchemy STATE_STORE_VERSION', async () => { - const version = await Effect.runPromise(service.getVersion()); - expect(version).toBe(5); - }); - - test('all 12 methods round-trip a resource and a stack output', async () => { - expect(await Effect.runPromise(service.listStacks())).toEqual([]); - - const value = createdResource({ fqn: 'app/db' }); - const setResult = await Effect.runPromise(service.set({ stack, stage, fqn: value.fqn, value })); - expect(setResult).toEqual(value); - - expect(await Effect.runPromise(service.listStacks())).toEqual([stack]); - expect(await Effect.runPromise(service.listStages(stack))).toEqual([stage]); - expect(await Effect.runPromise(service.list({ stack, stage }))).toEqual([value.fqn]); - - const fetched = await Effect.runPromise(service.get({ stack, stage, fqn: value.fqn })); - expect(fetched).toEqual(value); - - expect( - await Effect.runPromise(service.get({ stack, stage, fqn: 'does/not-exist' })), - ).toBeUndefined(); - - const outputValue = { url: 'https://example.test' }; - const setOutputResult = await Effect.runPromise( - service.setOutput({ stack, stage, value: outputValue }), - ); - expect(setOutputResult).toEqual(outputValue); - expect(await Effect.runPromise(service.getOutput({ stack, stage }))).toEqual(outputValue); - - await Effect.runPromise(service.delete({ stack, stage, fqn: value.fqn })); - expect(await Effect.runPromise(service.get({ stack, stage, fqn: value.fqn }))).toBeUndefined(); - }); - - test('list excludes stack outputs — resources and outputs live in separate tables', async () => { - const value = createdResource({ fqn: 'app/queue' }); - await Effect.runPromise(service.set({ stack, stage, fqn: value.fqn, value })); - await Effect.runPromise(service.setOutput({ stack, stage, value: { ok: true } })); - - expect(await Effect.runPromise(service.list({ stack, stage }))).toEqual([value.fqn]); - }); - - test('getReplacedResources filters to status = replaced', async () => { - const created = createdResource({ fqn: 'app/created' }); - const replaced = replacedResource({ fqn: 'app/replaced' }); - await Effect.runPromise(service.set({ stack, stage, fqn: created.fqn, value: created })); - await Effect.runPromise(service.set({ stack, stage, fqn: replaced.fqn, value: replaced })); - - const result = await Effect.runPromise(service.getReplacedResources({ stack, stage })); - expect(result).toEqual([replaced]); - }); - - test('deleteStack with a stage removes only that stage', async () => { - const other = 'other-stage'; - await Effect.runPromise( - service.set({ - stack, - stage, - fqn: 'app/a', - value: createdResource({ fqn: 'app/a' }), - }), - ); - await Effect.runPromise( - service.set({ - stack, - stage: other, - fqn: 'app/b', - value: createdResource({ fqn: 'app/b' }), - }), - ); - await Effect.runPromise(service.setOutput({ stack, stage, value: { a: true } })); - await Effect.runPromise(service.setOutput({ stack, stage: other, value: { b: true } })); - - await Effect.runPromise(service.deleteStack({ stack, stage })); - - expect(await Effect.runPromise(service.list({ stack, stage }))).toEqual([]); - expect(await Effect.runPromise(service.getOutput({ stack, stage }))).toBeUndefined(); - expect(await Effect.runPromise(service.list({ stack, stage: other }))).toEqual(['app/b']); - expect(await Effect.runPromise(service.getOutput({ stack, stage: other }))).toEqual({ - b: true, - }); - }); - - test('deleteStack without a stage removes every stage of the stack', async () => { - await Effect.runPromise( - service.set({ - stack, - stage, - fqn: 'app/a', - value: createdResource({ fqn: 'app/a' }), - }), - ); - await Effect.runPromise( - service.set({ - stack, - stage: 'other-stage', - fqn: 'app/b', - value: createdResource({ fqn: 'app/b' }), - }), - ); - - await Effect.runPromise(service.deleteStack({ stack })); - - expect(await Effect.runPromise(service.listStages(stack))).toEqual([]); - }); - - test('Redacted values round-trip byte-identically', async () => { - const value = createdResource({ - fqn: 'app/secret', - props: { token: Redacted.make('sk-live-abc123') }, - }); - await Effect.runPromise(service.set({ stack, stage, fqn: value.fqn, value })); - - const revived = await Effect.runPromise(service.get({ stack, stage, fqn: value.fqn })); - const props = (revived as CreatedResourceState | undefined)?.props; - expect(Redacted.isRedacted(props?.['token'])).toBe(true); - expect(Redacted.value(props?.['token'])).toBe('sk-live-abc123'); - }); - - test('Duration values round-trip', async () => { - const value = createdResource({ - fqn: 'app/ttl', - props: { ttl: Duration.seconds(30) }, - }); - await Effect.runPromise(service.set({ stack, stage, fqn: value.fqn, value })); - - const revived = await Effect.runPromise(service.get({ stack, stage, fqn: value.fqn })); - const props = (revived as CreatedResourceState | undefined)?.props; - expect(Duration.isDuration(props?.['ttl'])).toBe(true); - expect(Duration.toSeconds(props?.['ttl'])).toBe(30); - }); - - test('migratePrismaState is idempotent — running it twice does not throw', async () => { - await Effect.runPromise(migratePrismaState(sql)); - await Effect.runPromise(migratePrismaState(sql)); - - const value = createdResource({ fqn: 'app/post-remigrate' }); - await Effect.runPromise(service.set({ stack, stage, fqn: value.fqn, value })); - expect(await Effect.runPromise(service.get({ stack, stage, fqn: value.fqn }))).toEqual(value); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/transient.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/transient.test.ts deleted file mode 100644 index f048c03ca..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/transient.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { StateStoreError } from 'alchemy/State'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; -import { isColdStartConnectError, retryColdStart } from '../transient.ts'; - -describe('isColdStartConnectError', () => { - test('the PPg cold/idle-upstream rejection classifies as a cold start', () => { - // The exact message Run 1 died on (surfaced via toStateStoreError, which - // preserves the driver message on the StateStoreError). - expect( - isColdStartConnectError( - new StateStoreError({ message: 'Failed to connect to upstream database.' }), - ), - ).toBe(true); - }); - - test('establishment-refusal codes classify as a cold start', () => { - expect(isColdStartConnectError({ code: 'ECONNREFUSED', message: 'connect ECONNREFUSED' })).toBe( - true, - ); - expect(isColdStartConnectError({ code: 'ENOTFOUND', message: 'getaddrinfo ENOTFOUND' })).toBe( - true, - ); - expect(isColdStartConnectError({ code: 'EAI_AGAIN', message: 'getaddrinfo EAI_AGAIN' })).toBe( - true, - ); - }); - - test('unwraps a StateStoreError-style cause carrying the driver code', () => { - expect(isColdStartConnectError({ message: 'wrapped', cause: { code: 'ECONNREFUSED' } })).toBe( - true, - ); - }); - - test('a client-side dropped/terminated connection is NOT a cold start (the lost-lease signal)', () => { - // What postgres.js throws for a query after `sql.end()` — lock.ts's - // "lease-loss" test drives exactly this, and it must stay loud. - expect( - isColdStartConnectError({ - code: 'CONNECTION_ENDED', - message: 'write CONNECTION_ENDED 127.0.0.1:1', - }), - ).toBe(false); - expect(isColdStartConnectError({ code: 'ECONNRESET', message: 'read ECONNRESET' })).toBe(false); - }); - - test('a lost lease and a real query error are NOT cold starts', () => { - expect( - isColdStartConnectError( - new StateStoreError({ - message: 'the state lock for s/t was lost mid-run; refusing to continue unlocked', - }), - ), - ).toBe(false); - expect( - isColdStartConnectError({ message: 'duplicate key value violates unique constraint' }), - ).toBe(false); - }); - - test('non-object inputs are never cold starts', () => { - expect(isColdStartConnectError(undefined)).toBe(false); - expect(isColdStartConnectError(null)).toBe(false); - expect(isColdStartConnectError('upstream database')).toBe(false); - }); -}); - -describe('retryColdStart', () => { - // Instant schedule (no real delay) so tests don't wait the production window. - const instant = Schedule.recurs(10); - - test('retries past a cold-start rejection, then succeeds', async () => { - let attempts = 0; - const op = Effect.suspend(() => { - attempts += 1; - return attempts < 3 - ? Effect.fail(new StateStoreError({ message: 'Failed to connect to upstream database.' })) - : Effect.succeed('ok'); - }); - - const result = await Effect.runPromise(retryColdStart(op, instant)); - - expect(result).toBe('ok'); - expect(attempts).toBe(3); - }); - - test('does not retry a lost-lease failure — it surfaces on the first attempt', async () => { - let attempts = 0; - const op = Effect.suspend(() => { - attempts += 1; - return Effect.fail( - new StateStoreError({ - message: 'the state lock for s/t was lost mid-run; refusing to continue unlocked', - }), - ); - }); - - await expect(Effect.runPromise(retryColdStart(op, instant))).rejects.toThrow(/lost mid-run/); - expect(attempts).toBe(1); - }); -}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts deleted file mode 100644 index 5366610d0..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts +++ /dev/null @@ -1,269 +0,0 @@ -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; -import postgres from 'postgres'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import type { ResolvedContainer } from '../container.ts'; -import { call, callVoid, PrismaApiError } from '../http.ts'; -import { collectPages } from '../pagination.ts'; -import { - CONNECTION_NAME_PREFIX, - createConnection, - type DatabaseSummary, - listStateDatabaseCandidates, - resolveBranchId, - STATE_DATABASE_NAME, -} from './discovery.ts'; -import { STATE_META_MARKER } from './schema.ts'; - -const CONNECTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; - -interface ConnectionSummary { - readonly id: string; - readonly name: string; - readonly createdAt: string; -} - -export interface StateConnection { - readonly projectId: string; - /** The Branch the state database lives on. A `--stage ` deploy keeps its state on the Branch resolved for that name; a production deploy keeps its state on the project's default Branch. */ - readonly branchId: string; - readonly databaseId: string; - readonly connectionString: Redacted.Redacted; -} - -// ——— Databases ——— - -/** - * Creates the state database on the stage's Branch, using the flat endpoint - * because it is the only one that accepts a `branchId`. The platform still - * creates the row on the project's default Branch and attaches it afterwards, - * so a failed attach can leave a database behind on the default Branch. It - * checks the Branch exists before creating, and this is one request instead of - * two, so that window is much narrower than attaching from here — but it is - * narrowed, not closed. - * - * `isDefault` is left at the API's `false` default: the state database must - * never be the stage's default database, which belongs to the user. - * - * The region is explicit: `'inherit'` copies the project default database's - * region, but composer creates Projects with `createDatabase: false`, so - * there is nothing to inherit from and the API rejects it. `us-east-1` is - * what `'inherit'` always resolved to before (composer never set a project - * region), and matches the target's DEFAULT_REGION. - */ -const createStateDatabase = ( - client: ManagementApiClient, - projectId: string, - branchId: string, -): Effect.Effect => - call(() => - client.POST('/v1/databases', { - body: { projectId, name: STATE_DATABASE_NAME, region: 'us-east-1', branchId }, - }), - ).pipe( - Effect.map((created) => ({ - id: created.data.id, - name: created.data.name, - isDefault: created.data.isDefault, - createdAt: created.data.createdAt, - })), - ); - -// ——— Connections ——— - -const listAllConnections = ( - client: ManagementApiClient, - databaseId: string, -): Effect.Effect => - collectPages(`connections of database ${databaseId}`, (cursor) => - call(() => - client.GET('/v1/databases/{databaseId}/connections', { - params: { path: { databaseId }, query: cursor === undefined ? {} : { cursor } }, - }), - ), - ); - -const deleteConnection = ( - client: ManagementApiClient, - connectionId: string, -): Effect.Effect => - callVoid(() => client.DELETE('/v1/connections/{id}', { params: { path: { id: connectionId } } })); - -/** - * Every deploy creates a fresh connection (`createConnection`) and nothing ever - * closes it, so the state database otherwise accumulates one connection - * resource per run without bound. Best-effort, never blocks bootstrap: lists - * this database's connections, deletes the ones matching our naming pattern - * older than the age threshold, and swallows any failure (a transient API - * error here must never fail the deploy it's cleaning up after). - */ -const cleanupAgedConnections = ( - client: ManagementApiClient, - databaseId: string, -): Effect.Effect => - Effect.gen(function* () { - const connections = yield* listAllConnections(client, databaseId); - const cutoff = Date.now() - CONNECTION_MAX_AGE_MS; - const aged = connections.filter( - (c) => c.name.startsWith(CONNECTION_NAME_PREFIX) && Date.parse(c.createdAt) < cutoff, - ); - yield* Effect.forEach(aged, (c) => deleteConnection(client, c.id), { discard: true }); - }).pipe(Effect.ignore); - -// ——— Ownership verification ——— - -export type OwnershipVerdict = - | { readonly kind: 'ours' } - | { readonly kind: 'legacy' } - | { readonly kind: 'empty' } - | { readonly kind: 'squatter'; readonly tables: readonly string[] }; - -/** Decides whether a candidate database is ours. {@link verifyOwnership} is the real implementation; the tests pass a stub instead. */ -export type OwnershipVerifier = ( - connectionString: Redacted.Redacted, -) => Effect.Effect; - -/** - * PDP allows duplicate database names, so a database named `prisma-composer-state` - * found by listing is not proof it's ours — it could be an unrelated - * database that happens to share the name (a squatter, deliberate or not). - * Connects to the candidate and inspects its tables: - * - * - our marker table with our marker row present → `ours`, adopt outright. - * - our state tables (`alchemy_resource_state`/`alchemy_stack_output`) but no - * marker → `legacy`: a database from before this ownership check existed. - * The real, currently-in-use workspace state is in this shape today, so it - * must keep working — adopt it, and `migratePrismaState` (idempotent) - * writes the marker on the way in. - * - no tables at all → `empty`, a freshly-created database — adopt. - * - anything else → `squatter`: foreign data occupies the name; refuse it. - */ -export const verifyOwnership: OwnershipVerifier = (connectionString) => - Effect.tryPromise({ - try: async () => { - const sql = postgres(Redacted.value(connectionString), { max: 1, onnotice: () => {} }); - try { - const rows = await sql<{ tablename: string }[]>` - select tablename from pg_tables where schemaname = 'public' - `; - const tables = new Set(rows.map((row) => row.tablename)); - - if (tables.has('prisma_app_state_meta')) { - const marker = await sql<{ marker: string }[]>` - select marker from prisma_app_state_meta where marker = ${STATE_META_MARKER} - `; - return marker.length > 0 - ? ({ kind: 'ours' } as const) - : ({ kind: 'squatter', tables: [...tables] } as const); - } - if (tables.has('alchemy_resource_state') || tables.has('alchemy_stack_output')) { - return { kind: 'legacy' } as const; - } - return tables.size === 0 - ? ({ kind: 'empty' } as const) - : ({ kind: 'squatter', tables: [...tables] } as const); - } finally { - await sql.end({ timeout: 5 }); - } - }, - catch: (cause) => - new PrismaApiError({ - status: 0, - message: `ownership verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - -// ——— Orchestration ——— - -interface ResolvedStateDatabase { - readonly database: DatabaseSummary; - readonly connectionString: Redacted.Redacted; -} - -/** - * Finds the Branch's `prisma-composer-state` database, verifying ownership - * rather than trusting the name alone (PDP allows duplicate names — see - * `verifyOwnership`). Finding none creates one, with nothing to verify: only - * this run can have touched a database it just created. - * - * Candidates are tried oldest first, so repeated runs pick the same one. The - * first that verifies as ours is used. A candidate that fails verification is - * skipped. If every candidate fails, bootstrap fails and names each rejected - * database id, so an operator knows which to rename or remove. - */ -const resolveStateDatabase = ( - client: ManagementApiClient, - projectId: string, - branchId: string, - verify: OwnershipVerifier, -): Effect.Effect => - Effect.gen(function* () { - const candidates = yield* listStateDatabaseCandidates(client, projectId, branchId); - - if (candidates.length === 0) { - const database = yield* createStateDatabase(client, projectId, branchId); - const connectionString = yield* createConnection(client, database.id); - console.error( - `hosted state: provisioned state database ${database.id} on branch ${branchId} (project ${projectId})`, - ); - return { database, connectionString }; - } - - const rejected: string[] = []; - for (const candidate of candidates) { - const connectionString = yield* createConnection(client, candidate.id); - const verdict = yield* verify(connectionString); - if (verdict.kind === 'squatter') { - rejected.push(`${candidate.id} (foreign tables: ${verdict.tables.join(', ')})`); - continue; - } - console.error( - `hosted state: using state database ${candidate.id} on branch ${branchId} (${verdict.kind}) — ` + - `${candidates.length} candidate(s) named ${STATE_DATABASE_NAME}`, - ); - return { database: candidate, connectionString }; - } - - return yield* Effect.fail( - new PrismaApiError({ - status: 0, - message: - `found ${candidates.length} database(s) named "${STATE_DATABASE_NAME}" on branch ${branchId}, ` + - `but none verified as Composer's state store: ${rejected.join('; ')}. ` + - 'Rename or remove the offending database(s).', - }), - ); - }); - -/** - * Resolves the stage's Branch, find-or-creates its `prisma-composer-state` - * database, and creates a fresh connection — the automatic bootstrap every - * deploy runs once, needing nothing beyond the service token and the - * Project/Branch ids the CLI already resolved. - */ -export const bootstrapStateConnection = ( - container: ResolvedContainer, -): Effect.Effect => - bootstrapStateConnectionWith(container, verifyOwnership); - -/** - * Identical to {@link bootstrapStateConnection}, except the ownership check is - * a parameter so `bootstrap.test.ts` can supply a fake instead of opening a - * real Postgres connection. - */ -export const bootstrapStateConnectionWith = ( - container: ResolvedContainer, - verify: OwnershipVerifier, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* ManagementClient; - const branchId = yield* resolveBranchId(client, container); - const { database, connectionString } = yield* resolveStateDatabase( - client, - container.projectId, - branchId, - verify, - ); - yield* cleanupAgedConnections(client, database.id); - return { projectId: container.projectId, branchId, databaseId: database.id, connectionString }; - }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/delete.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/delete.ts deleted file mode 100644 index b704158e6..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/delete.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as Effect from 'effect/Effect'; -import { type ManagementApiClient, ManagementClient } from '../client.ts'; -import type { ResolvedContainer } from '../container.ts'; -import { callVoid, type PrismaApiError } from '../http.ts'; -import { type OwnershipVerifier, verifyOwnership } from './bootstrap.ts'; -import { - createConnection, - listStateDatabaseCandidates, - resolveBranchId, - STATE_DATABASE_NAME, -} from './discovery.ts'; - -const deleteDatabase = ( - client: ManagementApiClient, - databaseId: string, -): Effect.Effect => - callVoid(() => client.DELETE('/v1/databases/{databaseId}', { params: { path: { databaseId } } })); - -/** - * Removes the stage's state database, so the CLI's destroy leaves nothing - * behind: for a named stage the Branch cannot be deleted while this database - * is still a live member, and for production it would otherwise outlive the - * stage and hold a quota slot. - * - * Every candidate is checked for our ownership marker before deletion — - * deleting by name alone would destroy a user's database that happens to - * share the name. All owned candidates are removed, not just the first: a - * crashed earlier run can leave duplicates, and they are all ours. Finding - * none succeeds, which is what makes a retried destroy a no-op. - */ -export const deleteStateDatabase = ( - container: ResolvedContainer, -): Effect.Effect => - deleteStateDatabaseWith(container, verifyOwnership); - -/** - * Identical to {@link deleteStateDatabase}, except the ownership check is a - * parameter so tests can supply a fake instead of opening a real Postgres - * connection. - */ -export const deleteStateDatabaseWith = ( - container: ResolvedContainer, - verify: OwnershipVerifier, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* ManagementClient; - const branchId = yield* resolveBranchId(client, container); - const candidates = yield* listStateDatabaseCandidates(client, container.projectId, branchId); - - for (const candidate of candidates) { - const connectionString = yield* createConnection(client, candidate.id); - const verdict = yield* verify(connectionString); - if (verdict.kind === 'squatter') { - console.warn( - `hosted state: left database ${candidate.id} on branch ${branchId} alone — it is named ` + - `${STATE_DATABASE_NAME} but holds unrelated data (tables: ${verdict.tables.join(', ')}).`, - ); - continue; - } - yield* deleteDatabase(client, candidate.id); - console.error(`hosted state: removed state database ${candidate.id} from branch ${branchId}`); - } - }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts deleted file mode 100644 index 532b28666..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/discovery.ts +++ /dev/null @@ -1,104 +0,0 @@ -import * as Effect from 'effect/Effect'; -import * as Redacted from 'effect/Redacted'; -import type { ManagementApiClient } from '../client.ts'; -import { type ResolvedContainer, resolveDefaultBranchId } from '../container.ts'; -import { call, PrismaApiError } from '../http.ts'; -import { collectPages } from '../pagination.ts'; - -/** The framework-owned database a stage's deploy state lives in — a child of that stage's Branch (ADR-0034). */ -export const STATE_DATABASE_NAME = 'prisma-composer-state'; - -/** Every connection created against a state database carries this prefix — see `cleanupAgedConnections`. */ -export const CONNECTION_NAME_PREFIX = 'prisma-composer-state-'; - -export interface DatabaseSummary { - readonly id: string; - readonly name: string; - readonly isDefault: boolean; - readonly createdAt: string; -} - -/** A named stage carries its `branchId`; production carries `defaultBranchId` (its state lives on the Project's default Branch) — re-resolved only when neither is present. */ -export const resolveBranchId = ( - client: ManagementApiClient, - container: ResolvedContainer, -): Effect.Effect => { - const known = container.branchId ?? container.defaultBranchId; - return known !== undefined - ? Effect.succeed(known) - : resolveDefaultBranchId(client, container.projectId); -}; - -/** - * Every database on this Branch (bounded — drivePages). Uses the flat - * `GET /v1/databases`, which accepts `projectId` and `branchId` together — - * the project-scoped listing has no branch filter at all. - */ -const listAllDatabasesOnBranch = ( - client: ManagementApiClient, - projectId: string, - branchId: string, -): Effect.Effect => - collectPages(`databases on branch ${branchId}`, (cursor) => - call(() => - client.GET('/v1/databases', { - params: { - query: cursor === undefined ? { projectId, branchId } : { projectId, branchId, cursor }, - }, - }), - ), - ); - -/** - * Databases on this Branch named `prisma-composer-state`, oldest first, - * excluding the Branch's own default database. The default database is always - * the user's, never ours to adopt or delete. A name match alone proves - * nothing — the platform allows duplicate names — so every caller must still - * verify ownership before acting on a candidate. - */ -export const listStateDatabaseCandidates = ( - client: ManagementApiClient, - projectId: string, - branchId: string, -): Effect.Effect => - listAllDatabasesOnBranch(client, projectId, branchId).pipe( - Effect.map((databases) => - databases - .filter((d) => d.name === STATE_DATABASE_NAME && !d.isDefault) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt)), - ), - ); - -/** - * Creates a fresh Postgres connection and reads its connection string. Reads - * `endpoints.direct.connectionString` only — never `endpoints.pooled`, and - * never the deprecated top-level `connectionString`/`url` (PRO-212), neither - * of which the platform guarantees. - * - * The connection string is returned only when the connection is created and - * cannot be read back afterwards, which is why every run creates a fresh - * connection instead of reusing one. - */ -export const createConnection = ( - client: ManagementApiClient, - databaseId: string, -): Effect.Effect, PrismaApiError> => - call(() => - client.POST('/v1/databases/{databaseId}/connections', { - params: { path: { databaseId } }, - body: { name: `${CONNECTION_NAME_PREFIX}${Date.now()}` }, - }), - ).pipe( - Effect.flatMap((r) => { - const created = r.data; - const dsn = created.endpoints.direct?.connectionString; - return dsn === undefined - ? Effect.fail( - new PrismaApiError({ - status: 0, - message: `connection ${created.id} returned no endpoints.direct.connectionString (PRO-212)`, - }), - ) - : Effect.succeed(Redacted.make(dsn)); - }), - ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts index 43cc32129..bcb8dd49d 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts @@ -1,36 +1,43 @@ -import type { StateStoreError } from 'alchemy/State'; import * as Effect from 'effect/Effect'; -import type postgres from 'postgres'; +import * as Redacted from 'effect/Redacted'; import { type ManagementApiClient, ManagementClient } from '../client.ts'; import { call, PrismaApiError } from '../http.ts'; import { collectPages } from '../pagination.ts'; -import { toStateStoreError } from './errors.ts'; +import type { DeployLease, LeaseScope } from './lease.ts'; -/** Whether the (stack, stage) scope holds any rows in either state table. */ +/** + * Whether the platform state API holds any resources for (stack, stage). + * Requires the live deploy lease — the listing runs under it, like every + * state operation. + */ export const scopeOccupied = ( - sql: postgres.Sql, - stack: string, - stage: string, -): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const rows = await sql<{ occupied: boolean }[]>` - select - exists (select 1 from alchemy_resource_state where stack = ${stack} and stage = ${stage}) - or exists (select 1 from alchemy_stack_output where stack = ${stack} and stage = ${stage}) - as occupied - `; - return rows[0]?.occupied === true; - }, - catch: toStateStoreError, - }); + client: ManagementApiClient, + scope: LeaseScope, + lease: DeployLease, +): Effect.Effect => + call(() => + client.GET( + '/v1/projects/{projectId}/branches/{branchId}/alchemy-state/state/stacks/{stack}/stages/{stage}/resources', + { + params: { + path: { + projectId: scope.projectId, + branchId: scope.branchId, + stack: scope.stack, + stage: scope.stage, + }, + header: { 'alchemy-state-lease-id': Redacted.value(lease.leaseId) }, + }, + }, + ), + ).pipe(Effect.map((fqns) => fqns.length > 0)); interface AppSummary { readonly id: string; readonly name: string; } -// Bounded (collectPages): this check runs under the deploy lock, so broken +// Bounded (collectPages): this check runs under the deploy lease, so broken // pagination must fail loudly, never hang or pass on a partial listing. const listAppsOnBranch = ( client: ManagementApiClient, @@ -48,18 +55,16 @@ const listAppsOnBranch = ( ); /** - * The empty-scope-with-live-apps case: the branch-id scope holds no rows - * while the platform already runs Compute apps on the target Branch. Either - * this is a pre-branch-id deployment whose rows still sit under a legacy - * scope (`dev_$USER`, `unknown`, or the old stage name — there is no - * automatic migration; operator decision TML-3157), or the deploy targets a - * project that already runs apps. Deploying would recreate every resource - * and die in per-resource `already_exists` failures — so fail once, up - * front, naming the empty scope and what exists. Any app on the Branch is - * this deploy's concern: the Project is app-scoped and the Branch is the - * deploy's own target. A genuinely fresh deploy sees an empty Branch and - * passes. Local dev never reaches this — the dev stack pins - * `state: localState()` (generate-dev-stack.ts). + * The empty-scope-with-live-apps case: the platform state API holds no + * resources for (stack, stage) while the platform already runs Compute apps + * on the target Branch — this stage predates the platform state API (its + * state lives in a legacy `prisma-composer-state` database, which is never + * read; there is no automatic migration), or the deploy targets a project + * that already runs apps. Deploying would recreate every resource and die in + * per-resource `already_exists` failures — so fail once, up front. A + * genuinely fresh deploy sees an empty Branch and passes. Local dev never + * reaches this — the dev stack pins `state: localState()` + * (generate-dev-stack.ts). */ export const failOnEmptyScopeWithLiveApps = ( projectId: string, @@ -76,16 +81,13 @@ export const failOnEmptyScopeWithLiveApps = ( new PrismaApiError({ status: 0, message: - `the deploy state scope "${stage}" is empty, but the platform already runs ` + - `${String(apps.length)} app(s) on the target branch ${branchId}: ${names}. With no ` + - 'state, a deploy would recreate every resource and fail with already_exists, and a ' + - 'destroy would remove nothing. If those apps are a deployment from before the ' + - 'branch-id state scope, UPDATE the stage column of alchemy_resource_state and ' + - `alchemy_stack_output to '${stage}' WHERE stack = '${stack}' (the same database can ` + - "hold other stacks' rows) in this branch's prisma-composer-state database — or " + - 'delete the apps in the Prisma Console (or via the Management API) and redeploy ' + - "fresh. If they are another deployment's, remove them or deploy into a different " + - 'project. Then retry.', + `the platform state API holds no deploy state for stage "${stage}" (stack "${stack}"), but the ` + + `platform already runs ${String(apps.length)} app(s) on the target branch ${branchId}: ${names}. ` + + 'This stage predates the platform state API. With no state, a deploy would recreate every ' + + 'resource and fail with already_exists, and a destroy would remove nothing. Destroy the stage ' + + 'with the previous version of composer, or delete the stage (its branch — or the project, for ' + + 'production) in the Prisma Console or via the Management API — then redeploy fresh. If those ' + + "apps are another deployment's, remove them or deploy into a different project. Then retry.", }), ); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts index 83d1cd98d..6ecba3567 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/errors.ts @@ -1,17 +1,9 @@ -import { StateStoreError } from 'alchemy/State'; import * as Data from 'effect/Data'; -/** Collapses any thrown value (postgres.js failures included) into a {@link StateStoreError}. */ -export const toStateStoreError = (cause: unknown): StateStoreError => - cause instanceof Error - ? new StateStoreError({ message: cause.message, cause }) - : new StateStoreError({ message: String(cause) }); - /** * An operator-facing failure from the hosted-state bootstrap pipeline - * (branch/database discovery, connection creation, schema migration, or lock - * acquisition) — what a deployer actually sees, instead of a raw Effect - * defect. + * (branch resolution, lease acquisition, or the migration guard) — what a + * deployer actually sees, instead of a raw Effect defect. */ export class HostedStateBootstrapError extends Data.TaggedError('HostedStateBootstrapError')<{ /** The container the state store lives in: a Project id, or `projectId/branchId` for a named stage. */ @@ -26,10 +18,9 @@ export class HostedStateBootstrapError extends Data.TaggedError('HostedStateBoot /** * Builds a {@link HostedStateBootstrapError} from whatever the failed step - * threw. Never retains the raw driver/API error object as `cause`: a - * postgres.js connection failure's `.message`/properties are not verified to - * omit the DSN or credentials, so only the extracted message text survives - * into the operator-facing error. + * threw. Never retains the raw API error object as `cause`: only the + * extracted message text survives into the operator-facing error, so a + * credential or lease id carried on the raw error can never leak. */ export const hostedStateBootstrapError = ( container: string, diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index 7ff92db2e..d964dab97 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -1,44 +1,62 @@ import { Stack, type StackServices } from 'alchemy'; -import { State } from 'alchemy/State'; +import { makeHttpStateStore, State } from 'alchemy/State'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; -import * as Schedule from 'effect/Schedule'; -import postgres from 'postgres'; +import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient'; +import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; import * as client from '../client.ts'; +import { resolveDefaultBranchId } from '../container.ts'; import * as credentials from '../credentials.ts'; -import { bootstrapStateConnection } from './bootstrap.ts'; import { failOnEmptyScopeWithLiveApps, scopeOccupied } from './empty-scope.ts'; import { hostedStateBootstrapError } from './errors.ts'; -import { acquireStateLock } from './lock.ts'; -import { migratePrismaState } from './schema.ts'; -import { guardStateService, makePrismaStateService } from './service.ts'; +import { + acquireDeployLease, + heartbeatDeployLease, + LEASE_HEADER, + releaseDeployLease, +} from './lease.ts'; /** - * The hosted Alchemy state store. On layer init (scoped, once per stack - * run): resolve the stage's Branch, find-or-create its `prisma-composer-state` - * database, create a fresh connection, migrate the schema, and acquire the - * (stack, stage) advisory lock — see `bootstrap.ts` and `lock.ts`. The - * Management API plumbing (`ManagementClient`, `PrismaCredentials`) is + * The hosted Alchemy state store: alchemy's stock HTTP state client pointed + * at the platform state API + * (`/v1/projects/{projectId}/branches/{branchId}/alchemy-state`). On layer + * init (scoped, once per stack run): resolve the stage's Branch, acquire the + * (stack, stage) deploy lease, fork its heartbeat, run the migration guard, + * and build the stock store. Finalizers (reverse order): interrupt the + * heartbeat, release the lease. The Management API plumbing + * (`ManagementClient`, `PrismaCredentials`) and the store's `HttpClient` are * provided internally, so the returned layer's only requirements are the * ones alchemy itself already provides to every state store * (`StackServices`). * - * Any bootstrap/lock/migration failure is wrapped into an operator-facing + * Any bootstrap failure is wrapped into an operator-facing * `HostedStateBootstrapError` (naming the Project/Branch and the step that - * failed, never the raw driver/API error — see `errors.ts`) before dying the - * layer (loud, immediate, unrecoverable) rather than surfacing as a typed - * error — matching core's `LowerOptions.state: Layer.Layer` contract and alchemy's own convention (e.g. a missing - * state store is `Effect.die` in `Stack.make`). + * failed — see `errors.ts`) before dying the layer (loud, immediate, + * unrecoverable) rather than surfacing as a typed error — matching core's + * `LowerOptions.state: Layer.Layer` contract + * and alchemy's own convention (e.g. a missing state store is `Effect.die` + * in `Stack.make`). */ export const prismaStateLayer = (ids: { readonly projectId: string; readonly branchId?: string; - /** The project's default Branch id, when the deploy targets the default stage — lets bootstrap skip re-resolving it. */ + /** The project's default Branch id, when the deploy targets the default stage — skips re-resolving it. */ readonly defaultBranchId?: string; -}): Layer.Layer => { +}): Layer.Layer => + stateLayerAgainst(client.MANAGEMENT_API_ORIGIN, ids); + +/** `prismaStateLayer` with the API origin injectable — split out so tests can point it at a fake state API. */ +export const stateLayerAgainst = ( + apiOrigin: string, + ids: { + readonly projectId: string; + readonly branchId?: string; + readonly defaultBranchId?: string; + }, +): Layer.Layer => { const { projectId, branchId, defaultBranchId } = ids; + const dependencies = client.layer({ apiOrigin }).pipe(Layer.provideMerge(credentials.fromEnv())); return Layer.effect( State, @@ -48,61 +66,49 @@ export const prismaStateLayer = (ids: { const bootstrapError = (step: string) => (cause: unknown) => hostedStateBootstrapError(container, step, cause); - const bootstrapInput = { - projectId, - ...(branchId !== undefined ? { branchId } : {}), - ...(defaultBranchId !== undefined ? { defaultBranchId } : {}), - }; - const { connectionString, branchId: stateBranchId } = yield* bootstrapStateConnection( - bootstrapInput, - ).pipe( - Effect.provide(client.layer().pipe(Layer.provide(credentials.fromEnv()))), - Effect.mapError(bootstrapError('resolving the state database on the stage branch')), - ); + const mgmt = yield* client.ManagementClient; + const { token } = yield* credentials.PrismaCredentials; - // The pool reconnects on demand for ordinary (non-reserved) queries — - // postgres.js's default behaviour — which is what absorbs PPg closing - // idle direct connections (FT-5219 class) for the store's CRUD calls. - // The lock's reserved connection is deliberately exempt from this: see - // `lock.ts`'s `checkLive`. - const sql = postgres(Redacted.value(connectionString), { - max: 5, - onnotice: () => {}, - }); - yield* Effect.addFinalizer(() => Effect.promise(() => sql.end({ timeout: 5 }))); + // A named stage carries its branchId; production carries defaultBranchId — + // re-resolved via the Management API only when neither is present. + const stateBranchId = + branchId ?? + defaultBranchId ?? + (yield* resolveDefaultBranchId(mgmt, projectId).pipe( + Effect.mapError(bootstrapError('resolving the stage branch')), + )); - // The migration is the pool's first query, i.e. the first actual - // connect. A freshly provisioned database (first-ever bootstrap in a - // workspace) can refuse connections for a while after the Management - // API returns it, so retry the window out before failing. - yield* migratePrismaState(sql).pipe( - Effect.retry(Schedule.spaced('5 seconds').pipe(Schedule.upTo({ duration: '2 minutes' }))), - Effect.mapError(bootstrapError('schema migration')), - ); + const scope = { projectId, branchId: stateBranchId, stack: stack.name, stage: stack.stage }; - const lock = yield* acquireStateLock(sql, stack.name, stack.stage).pipe( - Effect.mapError(bootstrapError('lock acquisition')), + const lease = yield* acquireDeployLease(mgmt, scope).pipe( + Effect.mapError(bootstrapError('acquiring the deploy lease')), ); - yield* Effect.addFinalizer(() => Effect.promise(() => lock.release())); + yield* Effect.addFinalizer(() => releaseDeployLease(mgmt, scope, lease)); + yield* Effect.forkScoped(heartbeatDeployLease(mgmt, scope, lease)); - // Under the lock, before the service exists — so the check precedes - // Alchemy's first state read. An empty scope with live apps on the - // Branch means a pre-branch-id deployment (rows under a legacy scope — - // no automatic migration, operator decision TML-3157) or a foreign - // deployment: refuse before Alchemy mutates any resource. - const occupied = yield* scopeOccupied(sql, stack.name, stack.stage).pipe( + // Before the store exists — so the check precedes Alchemy's first state + // read. An empty scope with live apps on the Branch means the stage + // predates the platform state API (or a foreign deployment): refuse + // before Alchemy mutates any resource. + const occupied = yield* scopeOccupied(mgmt, scope, lease).pipe( Effect.mapError(bootstrapError('probing the deploy state scope')), ); if (!occupied) { yield* failOnEmptyScopeWithLiveApps(projectId, stateBranchId, stack.name, stack.stage).pipe( - Effect.provide(client.layer().pipe(Layer.provide(credentials.fromEnv()))), + Effect.provideService(client.ManagementClient, mgmt), Effect.mapError(bootstrapError('checking the empty deploy state scope')), ); } - const service = guardStateService(makePrismaStateService(sql), lock.checkLive); + const service = yield* makeHttpStateStore({ + url: `${apiOrigin}/v1/projects/${projectId}/branches/${stateBranchId}/alchemy-state`, + authToken: Redacted.value(token), + transformClient: (req) => + HttpClientRequest.setHeader(req, LEASE_HEADER, Redacted.value(lease.leaseId)), + id: 'prisma-postgres', + }).pipe(Effect.provide(FetchHttpClient.layer)); return Effect.succeed(service); - }), + }).pipe(Effect.provide(dependencies)), ).pipe(Layer.orDie); }; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts new file mode 100644 index 000000000..2b4aea3a3 --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts @@ -0,0 +1,152 @@ +import * as os from 'node:os'; +import type * as Duration from 'effect/Duration'; +import * as Effect from 'effect/Effect'; +import * as Redacted from 'effect/Redacted'; +import type { ManagementApiClient } from '../client.ts'; +import { PrismaApiError } from '../http.ts'; + +/** The header every state operation and lease call carries. Its value is a capability token — never log it. */ +export const LEASE_HEADER = 'Alchemy-State-Lease-Id'; + +const LEASE_PATH = '/v1/projects/{projectId}/branches/{branchId}/alchemy-state/lease'; + +export interface LeaseScope { + readonly projectId: string; + readonly branchId: string; + readonly stack: string; + readonly stage: string; +} + +export interface DeployLease { + readonly leaseId: Redacted.Redacted; + readonly expiresAt: string; +} + +/** The server names the current holder in its 409 message; pass it through verbatim, hint appended. */ +const serverErrorText = (error: { error: { message: string; hint?: string } }): string => + error.error.hint === undefined + ? error.error.message + : `${error.error.message} ${error.error.hint}`; + +const transportError = (cause: unknown): PrismaApiError => + new PrismaApiError({ status: 0, message: String(cause) }); + +/** Best-effort `user@host`, echoed in the contention error a blocked deploy sees. */ +const holderDescription = (): string => { + try { + return `${os.userInfo().username}@${os.hostname()}`; + } catch { + return 'unknown'; + } +}; + +/** + * Acquires the (stack, stage) deploy lease. Contention (409) fails fast with + * the server's message naming the current holder — no queueing, no retry. + * The server's default TTL (60s) applies; no `ttlSeconds` is sent. + */ +export const acquireDeployLease = ( + client: ManagementApiClient, + scope: LeaseScope, +): Effect.Effect => + Effect.tryPromise({ + try: () => + client.POST(LEASE_PATH, { + params: { path: { projectId: scope.projectId, branchId: scope.branchId } }, + body: { + stack: scope.stack, + stage: scope.stage, + holderDescription: holderDescription(), + }, + }), + catch: transportError, + }).pipe( + Effect.flatMap((r) => { + const status = r.response.status; + if (r.error !== undefined) { + return Effect.fail(new PrismaApiError({ status, message: serverErrorText(r.error) })); + } + if (r.data !== undefined) { + return Effect.succeed({ + leaseId: Redacted.make(r.data.data.leaseId), + expiresAt: r.data.data.expiresAt, + }); + } + return Effect.fail( + new PrismaApiError({ + status, + message: `lease acquisition returned HTTP ${String(status)} with no error body`, + }), + ); + }), + ); + +/** + * Extends the lease on a fixed cadence (TTL/3) until interrupted. A 404 means + * the lease was lost — log one loud warning and stop; enforcement is + * server-side, so the run's next state operation fails with 409. Any other + * heartbeat failure is ignored and the next tick retries; the heartbeat never + * fails the run. + */ +export const heartbeatDeployLease = ( + client: ManagementApiClient, + scope: LeaseScope, + lease: DeployLease, + every: Duration.Input = '20 seconds', +): Effect.Effect => + Effect.gen(function* () { + while (true) { + yield* Effect.sleep(every); + const status = yield* Effect.tryPromise(() => + client.PATCH(LEASE_PATH, { + params: { + path: { projectId: scope.projectId, branchId: scope.branchId }, + header: { 'alchemy-state-lease-id': Redacted.value(lease.leaseId) }, + }, + }), + ).pipe( + Effect.map((r) => r.response.status), + Effect.catch(() => Effect.succeed(0)), + ); + if (status === 404) { + yield* Effect.logWarning( + `the deploy lease for stage "${scope.stage}" was lost (heartbeat returned 404) — ` + + 'another deploy may have taken over; the next state operation of this run will fail.', + ); + return; + } + } + }); + +/** + * Releases the lease on clean exit. Never fails: a 404 (lease already + * expired or replaced) or any other failure is logged, not thrown — the run + * already completed. + */ +export const releaseDeployLease = ( + client: ManagementApiClient, + scope: LeaseScope, + lease: DeployLease, +): Effect.Effect => + Effect.tryPromise(() => + client.DELETE(LEASE_PATH, { + params: { + path: { projectId: scope.projectId, branchId: scope.branchId }, + header: { 'alchemy-state-lease-id': Redacted.value(lease.leaseId) }, + }, + }), + ).pipe( + Effect.flatMap((r) => + r.response.status === 404 + ? Effect.logWarning( + `releasing the deploy lease for stage "${scope.stage}" returned 404 — ` + + 'it had already expired or been replaced.', + ) + : Effect.void, + ), + Effect.catch((cause) => + Effect.logWarning( + `releasing the deploy lease for stage "${scope.stage}" failed: ${String(cause)}`, + ), + ), + ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/lock.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/lock.ts deleted file mode 100644 index 505c41f6d..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/lock.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { StateStoreError } from 'alchemy/State'; -import * as Data from 'effect/Data'; -import * as Effect from 'effect/Effect'; -import type postgres from 'postgres'; -import { toStateStoreError } from './errors.ts'; -import { retryColdStart } from './transient.ts'; - -/** Another deploy already holds the lock for this stack/stage. Never queued — fails immediately. */ -export class StateLockContentionError extends Data.TaggedError('StateLockContentionError')<{ - readonly stack: string; - readonly stage: string; -}> { - override get message(): string { - return `another deploy holds the state lock for ${this.stack}/${this.stage}`; - } -} - -export interface StateLock { - /** - * Re-verifies the lease is still held. Every state operation runs this - * first: if the lease is gone (e.g. an idle-closed direct connection — - * FT-5219 class), this fails loudly instead of letting the operation run - * unlocked. - */ - readonly checkLive: Effect.Effect; - /** Unlocks and releases the reserved connection. Safe to call once the run ends. */ - readonly release: () => Promise; -} - -// Built here (not `select ... where 'prisma-composer:' || stack || '/' || stage`) -// so the lock id is computed once, in one place, from the same string every -// caller (JS or a human reading logs) would produce. -const lockKey = (stack: string, stage: string): string => `prisma-composer:${stack}/${stage}`; - -/** - * Acquires a session-scoped Postgres advisory lock on a reserved - * connection pulled from `sql`'s pool — session (not transaction) scope, - * because a transaction-scoped lock releases at the first commit and a - * deploy spans many. Held for the run's whole lifetime; contention fails - * immediately rather than queuing. If the process crashes, the reserved - * connection drops and Postgres auto-releases the session lock — no - * explicit crash-recovery bookkeeping needed. - */ -export const acquireStateLock = ( - sql: postgres.Sql, - stack: string, - stage: string, -): Effect.Effect => - Effect.gen(function* () { - const key = lockKey(stack, stage); - const reserved = yield* Effect.tryPromise({ - try: () => sql.reserve(), - catch: toStateStoreError, - }); - - const acquired = yield* Effect.tryPromise({ - try: async () => { - const rows = await reserved<{ acquired: boolean; pid: number }[]>` - select - pg_try_advisory_lock(hashtextextended(${key}, 0)) as acquired, - pg_backend_pid() as pid - `; - return rows[0]; - }, - catch: toStateStoreError, - }); - - if (acquired?.acquired !== true) { - reserved.release(); - return yield* Effect.fail(new StateLockContentionError({ stack, stage })); - } - - const lockPid = acquired.pid; - - // Deliberately does NOT run a query against the reserved connection - // itself. Once its backend session has been killed server-side (an - // idle-connection reaper, FT-5219 class), postgres.js does not - // transparently reconnect a reserved connection, but issuing a further - // query against it doesn't cleanly reject either — it throws deep - // inside postgres.js's deferred write path, outside the query's promise - // chain, which can crash the whole process instead of failing this - // check. Asking a *different* pool connection whether the backend pid - // captured at acquire time still holds this advisory lock in - // `pg_locks` gets the same answer (a dead or reused backend can't hold - // the lock) without ever touching the connection that might be dead. - // Retry only a cold-start on the check's own connection; a lost lease - // (`live` = false) and a dropped/terminated connection both stay loud — - // the former is not a connection error, the latter is a mid-session drop - // the cold-start predicate deliberately excludes (see `transient.ts`). - const checkLive: Effect.Effect = retryColdStart( - Effect.tryPromise({ - try: async () => { - const rows = await sql<{ live: boolean }[]>` - select exists ( - select 1 from pg_locks - where locktype = 'advisory' - and pid = ${lockPid} - and objsubid = 1 - and granted - and ((classid::bigint << 32) | (objid::bigint & 4294967295)) - = hashtextextended(${key}, 0) - ) as live - `; - return rows[0]?.live ?? false; - }, - catch: toStateStoreError, - }).pipe( - Effect.flatMap((live) => - live - ? Effect.void - : Effect.fail( - new StateStoreError({ - message: `the state lock for ${stack}/${stage} was lost mid-run; refusing to continue unlocked`, - }), - ), - ), - ), - ); - - const release = async (): Promise => { - try { - await reserved`select pg_advisory_unlock(hashtextextended(${key}, 0))`; - } catch { - // The connection already dropped — Postgres auto-releases a - // session-scoped advisory lock when the session ends, so there is - // nothing left to unlock. - } finally { - reserved.release(); - } - }; - - return { checkLive, release }; - }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/schema.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/schema.ts deleted file mode 100644 index baeed796e..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/schema.ts +++ /dev/null @@ -1,58 +0,0 @@ -// TODO: replace with Prisma Next (deferred — see docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md) - -import type { StateStoreError } from 'alchemy/State'; -import * as Effect from 'effect/Effect'; -import type postgres from 'postgres'; -import { toStateStoreError } from './errors.ts'; - -/** - * The well-known marker row written into every database this store owns. - * Its presence proves the database is genuinely Prisma App's state store, not - * a same-named project squatting on the discovery query (see `bootstrap.ts` - * `verifyOwnership` — PDP allows duplicate project names). - */ -export const STATE_META_MARKER = 'prisma-composer-state-v1'; - -/** - * Idempotent schema migration for the Prisma-hosted state store — safe to run - * on every deploy, since `create table if not exists` no-ops once the tables - * exist. Run this against `sql` before serving a {@link StateService} built - * by `makePrismaStateService` over the same client. - */ -export const migratePrismaState = ( - sql: postgres.Sql, -): Effect.Effect => - Effect.tryPromise({ - try: async () => { - await sql` - create table if not exists alchemy_resource_state ( - stack text not null, - stage text not null, - fqn text not null, - value jsonb not null, - updated_at timestamptz not null default now(), - primary key (stack, stage, fqn) - ) - `; - await sql` - create table if not exists alchemy_stack_output ( - stack text not null, - stage text not null, - value jsonb not null, - updated_at timestamptz not null default now(), - primary key (stack, stage) - ) - `; - await sql` - create table if not exists prisma_app_state_meta ( - marker text primary key, - created_at timestamptz not null default now() - ) - `; - await sql` - insert into prisma_app_state_meta (marker) values (${STATE_META_MARKER}) - on conflict (marker) do nothing - `; - }, - catch: toStateStoreError, - }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts deleted file mode 100644 index bc271ff74..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/service.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { blindCast } from '@internal/foundation/casts'; -import { - encodeState, - type PersistedState, - type ReplacedResourceState, - reviveStateRecursive, - STATE_STORE_VERSION, - type StateService, - type StateStoreError, -} from 'alchemy/State'; -import * as Effect from 'effect/Effect'; -import type postgres from 'postgres'; -import { toStateStoreError } from './errors.ts'; -import { retryColdStart } from './transient.ts'; - -const attempt = (f: () => Promise): Effect.Effect => - retryColdStart(Effect.tryPromise({ try: f, catch: toStateStoreError })); - -/** - * Wraps an already-`encodeState`d value as a jsonb-typed bind parameter. - * Must go through `sql.json(...)` (not `JSON.stringify(...)::jsonb`) — - * postgres.js re-serializes the parameter once it learns the server-inferred - * type is jsonb, so a pre-stringified value passed through a `::jsonb` cast - * gets JSON-encoded *twice* and lands as a jsonb string instead of an - * object. `sql.json` gives postgres.js the raw value up front and declares - * the jsonb oid itself, so it is serialized exactly once. - */ -const jsonParam = (sql: postgres.Sql, value: unknown): postgres.Parameter => - sql.json( - blindCast< - postgres.JSONValue, - 'encodeState is typed to return unknown (it recursively walks an arbitrary PersistedState/output value); the result is always a JSON-safe shape by construction, which is what JSONValue describes' - >(encodeState(value)), - ); - -/** - * `reviveStateRecursive` is typed to return `unknown` — the caller is - * expected to know the shape it revived. Here the shape is known by - * construction: every row was written by `set()`, which persists a value - * through `encodeState` first, so reviving it recovers a `PersistedState`. - */ -const revivePersistedState = (value: unknown): PersistedState => - blindCast< - PersistedState, - 'reviveStateRecursive returns unknown; the row was written by set() through encodeState, so the revived shape is a PersistedState by construction' - >(reviveStateRecursive(value)); - -/** Same reasoning as {@link revivePersistedState}, narrowed by the SQL status filter. */ -const reviveReplacedResourceState = (value: unknown): ReplacedResourceState => - blindCast< - ReplacedResourceState, - "filtered to status = 'replaced' in SQL; the row was written by set() through encodeState, so the revived shape is a ReplacedResourceState by construction" - >(reviveStateRecursive(value)); - -/** - * Builds alchemy's `StateService` over a caller-supplied postgres.js client, - * against the two-table schema `migratePrismaState` creates. The caller owns - * the client's lifecycle (connection pooling, reconnects, `.end()`); this - * factory only issues queries. - */ -export const makePrismaStateService = (sql: postgres.Sql): StateService => ({ - id: 'prisma-postgres', - - getVersion: () => Effect.succeed(STATE_STORE_VERSION), - - listStacks: () => - attempt( - () => sql<{ stack: string }[]>` - select stack from alchemy_resource_state - union - select stack from alchemy_stack_output - order by stack - `, - ).pipe(Effect.map((rows) => rows.map((row) => row.stack))), - - listStages: (stack) => - attempt( - () => sql<{ stage: string }[]>` - select stage from alchemy_resource_state where stack = ${stack} - union - select stage from alchemy_stack_output where stack = ${stack} - order by stage - `, - ).pipe(Effect.map((rows) => rows.map((row) => row.stage))), - - get: (request) => - attempt( - () => sql<{ value: unknown }[]>` - select value from alchemy_resource_state - where stack = ${request.stack} and stage = ${request.stage} and fqn = ${request.fqn} - `, - ).pipe( - Effect.map((rows) => { - const row = rows[0]; - return row === undefined ? undefined : revivePersistedState(row.value); - }), - ), - - // Filters by status = 'replaced' directly in SQL rather than listing FQNs - // and fetching each one (LocalState's approach, which reads a directory - // then re-reads every file) — same semantics, avoids the N+1. - getReplacedResources: (request) => - attempt( - () => sql<{ value: unknown }[]>` - select value from alchemy_resource_state - where stack = ${request.stack} and stage = ${request.stage} - and value ->> 'status' = 'replaced' - `, - ).pipe(Effect.map((rows) => rows.map((row) => reviveReplacedResourceState(row.value)))), - - set: (request) => - attempt( - () => sql` - insert into alchemy_resource_state (stack, stage, fqn, value, updated_at) - values ( - ${request.stack}, ${request.stage}, ${request.fqn}, - ${jsonParam(sql, request.value)}, now() - ) - on conflict (stack, stage, fqn) do update - set value = excluded.value, updated_at = excluded.updated_at - `, - ).pipe(Effect.map(() => request.value)), - - delete: (request) => - attempt( - () => sql` - delete from alchemy_resource_state - where stack = ${request.stack} and stage = ${request.stage} and fqn = ${request.fqn} - `, - ).pipe(Effect.asVoid), - - deleteStack: (request) => - attempt(async () => { - if (request.stage === undefined) { - await sql`delete from alchemy_resource_state where stack = ${request.stack}`; - await sql`delete from alchemy_stack_output where stack = ${request.stack}`; - } else { - await sql` - delete from alchemy_resource_state - where stack = ${request.stack} and stage = ${request.stage} - `; - await sql` - delete from alchemy_stack_output - where stack = ${request.stack} and stage = ${request.stage} - `; - } - }), - - list: (request) => - attempt( - () => sql<{ fqn: string }[]>` - select fqn from alchemy_resource_state - where stack = ${request.stack} and stage = ${request.stage} - order by fqn - `, - ).pipe(Effect.map((rows) => rows.map((row) => row.fqn))), - - getOutput: (request) => - attempt( - () => sql<{ value: unknown }[]>` - select value from alchemy_stack_output - where stack = ${request.stack} and stage = ${request.stage} - `, - ).pipe( - Effect.map((rows) => { - const row = rows[0]; - return row === undefined ? undefined : reviveStateRecursive(row.value); - }), - ), - - setOutput: (request) => - attempt( - () => sql` - insert into alchemy_stack_output (stack, stage, value, updated_at) - values (${request.stack}, ${request.stage}, ${jsonParam(sql, request.value)}, now()) - on conflict (stack, stage) do update - set value = excluded.value, updated_at = excluded.updated_at - `, - ).pipe(Effect.map(() => request.value)), -}); - -/** - * How long a passing lease check is trusted before the next storage - * operation re-verifies it. A deploy issues many state ops in quick - * succession, and each raw `checkLive` is a `pg_locks` round-trip; without - * amortization the guard roughly doubles the store's traffic. The cost of the - * window is bounded: a lease lost mid-window is detected within this many ms, - * not instantly — an accepted tradeoff on top of the already-accepted - * non-atomic (TOCTOU) gap between the check and the operation. - */ -const LEASE_CHECK_TTL_MS = 5_000; - -/** - * Amortizes a lease check over a short TTL: a *passing* check is trusted for - * `ttlMs`, so a burst of operations inside the window does one round-trip, not - * one per op. A *failing* check is never cached — it propagates immediately - * and leaves the last-good timestamp untouched, so the very next op re-checks. - * The `lastOkAt` state is captured per call, so each store (each layer) gets - * its own window — two stores in one process never share a cached success. - */ -const amortizeCheck = ( - checkLive: Effect.Effect, - ttlMs: number, - now: () => number, -): Effect.Effect => { - let lastOkAt: number | undefined; - return Effect.suspend(() => { - if (lastOkAt !== undefined && now() - lastOkAt < ttlMs) return Effect.void; - return checkLive.pipe( - Effect.tap(() => - Effect.sync(() => { - lastOkAt = now(); - }), - ), - ); - }); -}; - -/** - * Wraps a {@link StateService} so every method that touches storage first - * re-verifies the state lock's lease via `checkLive`. Used to enforce "a - * dropped lock connection fails loudly" — see {@link ../lock.ts}. The check is - * amortized over a short TTL (see {@link amortizeCheck}), so a run of - * back-to-back operations does not fire one `pg_locks` round-trip per call. - * - * Reads are gated too, not just writes: a lost lease means a concurrent - * deploy may already be mutating this stack's rows, so a read could return - * stale or conflicting data — untrustworthy either way, not just the writes. - * The check is best-effort and not atomic with the operation it guards (the - * lease could be lost in the gap between `checkLive` passing and the wrapped - * call executing, or within the TTL window); that residual race is accepted. - * - * `getVersion` is excluded: it returns a compile-time constant - * (`STATE_STORE_VERSION`), so guarding it would only add a pointless - * reserved-connection round-trip. - * - * `now` is injectable so tests can advance the clock deterministically; it - * defaults to `Date.now` (fine in library runtime code — only Workflow - * scripts forbid it). - */ -export const guardStateService = ( - service: StateService, - checkLive: Effect.Effect, - now: () => number = Date.now, -): StateService => { - const guard = amortizeCheck(checkLive, LEASE_CHECK_TTL_MS, now); - return { - id: service.id, - getVersion: () => service.getVersion(), - listStacks: () => guard.pipe(Effect.andThen(service.listStacks())), - listStages: (stack) => guard.pipe(Effect.andThen(service.listStages(stack))), - get: (request) => guard.pipe(Effect.andThen(service.get(request))), - getReplacedResources: (request) => - guard.pipe(Effect.andThen(service.getReplacedResources(request))), - set: (request) => guard.pipe(Effect.andThen(service.set(request))), - delete: (request) => guard.pipe(Effect.andThen(service.delete(request))), - deleteStack: (request) => guard.pipe(Effect.andThen(service.deleteStack(request))), - list: (request) => guard.pipe(Effect.andThen(service.list(request))), - getOutput: (request) => guard.pipe(Effect.andThen(service.getOutput(request))), - setOutput: (request) => guard.pipe(Effect.andThen(service.setOutput(request))), - }; -}; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts deleted file mode 100644 index 003bd142e..000000000 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/transient.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Retrying state-store queries past a Prisma Postgres cold-start (FT-5226). - * - * A freshly provisioned or idle-resumed PPg database refuses connections while - * its upstream warms up: the edge proxy answers "Failed to connect to upstream - * database" until the real Postgres is reachable. The bootstrap migration - * already rides this out (see `layer.ts`), but the per-op state queries that - * run afterwards (plan/apply/destroy) did not — so a state DB that had gone - * idle between bootstrap and those queries failed the deploy outright. - * - * The retry is deliberately scoped to connection *establishment* failures — - * a warming upstream. Mid-session drops (a terminated/reset connection) are - * NOT retried: for the state store those are the lost-lease signal that - * `lock.ts`'s `checkLive` must surface loudly, not paper over. This makes the - * set narrower than @internal/prisma-cloud's `pg-connection.ts` (which also - * retries mid-session drops for the runtime store client); that helper can't - * be imported here regardless — @internal/prisma-cloud depends on - * @internal/lowering, so importing it back would cycle. - */ -import type { StateStoreError } from 'alchemy/State'; -import * as Effect from 'effect/Effect'; -import * as Schedule from 'effect/Schedule'; - -/** Codes for "can't reach the host yet" — DNS/refusal, not a mid-session drop. */ -const ESTABLISHMENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']); - -/** Connection-establishment failure messages (no useful `err.code`). `upstream database` is PPg's edge proxy while a cold/idle upstream warms up. */ -const ESTABLISHMENT_MESSAGE_FRAGMENTS = ['upstream database', 'connection refused']; - -/** - * Whether a failure is a connection-establishment error against a warming - * upstream (retry), as opposed to a lost lease, a mid-session drop, or a real - * query error (all of which must surface at once). Unwraps a - * {@link StateStoreError} to its driver `cause` so a code-only error still - * classifies. - */ -export const isColdStartConnectError = (error: unknown): boolean => { - if (typeof error !== 'object' || error === null) return false; - const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined; - if (code !== undefined && ESTABLISHMENT_CODES.has(code)) return true; - const message = - 'message' in error && typeof error.message === 'string' ? error.message.toLowerCase() : ''; - if (ESTABLISHMENT_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment))) return true; - const cause = 'cause' in error ? error.cause : undefined; - return cause !== undefined && cause !== error && isColdStartConnectError(cause); -}; - -/** The same ~2-minute budget the bootstrap migration uses (`layer.ts`): retry every 5s, up to 2 minutes. */ -const COLD_START_SCHEDULE = Schedule.spaced('5 seconds').pipe( - Schedule.upTo({ duration: '2 minutes' }), -); - -/** - * Retries a state operation past a cold-start connection rejection only; every - * other failure surfaces immediately. `schedule` is injectable so tests drive - * it without real delay. - */ -export const retryColdStart = ( - operation: Effect.Effect, - schedule: Schedule.Schedule = COLD_START_SCHEDULE, -): Effect.Effect => - Effect.retry(operation, { while: isColdStartConnectError, schedule }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/teardown.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/teardown.test.ts deleted file mode 100644 index 3fa501c38..000000000 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/teardown.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { beforeEach, describe, expect, spyOn, test } from 'bun:test'; -import type { ManagementApiClient } from '@internal/lowering'; -import type { OwnershipVerifier } from '@internal/lowering/state'; -import * as Effect from 'effect/Effect'; -import { PrismaCloudContainer } from '../container.ts'; -import { runTeardown } from '../teardown.ts'; - -/** Every candidate verifies as ours — the real verifier would open a Postgres connection. */ -const ours: OwnershipVerifier = () => Effect.succeed({ kind: 'ours' }); - -/** A resolved container matching `input.container` after the boundary move — teardown narrows it with `prismaCloudContainerOf`. */ -const fakeContainer = (projectId: string, branchId: string | undefined) => - new PrismaCloudContainer({ appName: 'app', stage: undefined }, projectId, branchId); - -interface FakeDatabase { - id: string; - name: string; - isDefault: boolean; - createdAt: string; - branchId: string | null; -} - -interface FakeState { - branches: { id: string; isDefault: boolean }[]; - databases: FakeDatabase[]; - deletedDatabaseIds: string[]; - /** Overrides the database DELETE status — defaults to a 204 success. */ - deleteStatus: number; -} - -const newFakeState = (overrides: Partial = {}): FakeState => ({ - branches: [{ id: 'br-default', isDefault: true }], - databases: [], - deletedDatabaseIds: [], - deleteStatus: 204, - ...overrides, -}); - -const ok = (data: T, status = 200) => ({ - data, - error: undefined, - response: new Response(null, { status }), -}); - -/** - * A stubbed Management API client — test file, exempt from the no-bare-cast - * rule. Answers only the paths teardown's discovery walks: the project's - * branches, the flat database listing, connection creation, and the database - * delete. - */ -const fakeClient = (state: FakeState): ManagementApiClient => - ({ - GET: async (path: string, init: { params?: { query?: Record } }) => { - if (path === '/v1/projects/{projectId}/branches') { - return ok({ data: state.branches, pagination: { nextCursor: null, hasMore: false } }); - } - if (path === '/v1/databases') { - const branchId = init.params?.query?.['branchId']; - return ok({ - data: state.databases.filter((d) => d.branchId === branchId), - pagination: { nextCursor: null, hasMore: false }, - }); - } - throw new Error(`fakeClient: unexpected GET ${path}`); - }, - POST: async (path: string, init: { params?: { path?: Record } }) => { - if (path === '/v1/databases/{databaseId}/connections') { - const databaseId = init.params?.path?.['databaseId'] ?? ''; - return ok({ - data: { - id: `conn-${databaseId}`, - endpoints: { direct: { connectionString: `postgres://fake/${databaseId}` } }, - }, - }); - } - throw new Error(`fakeClient: unexpected POST ${path}`); - }, - DELETE: async (path: string, init: { params?: { path?: Record } }) => { - if (path === '/v1/databases/{databaseId}') { - const databaseId = init.params?.path?.['databaseId'] ?? ''; - if (state.deleteStatus !== 204) { - return { - data: undefined, - error: { code: 'conflict', message: 'refused' }, - response: new Response(null, { status: state.deleteStatus }), - }; - } - state.deletedDatabaseIds.push(databaseId); - return ok(undefined, 204); - } - throw new Error(`fakeClient: unexpected DELETE ${path}`); - }, - }) as unknown as ManagementApiClient; - -const stateDatabase = (id: string, branchId: string): FakeDatabase => ({ - id, - name: 'prisma-composer-state', - isDefault: false, - createdAt: new Date(1).toISOString(), - branchId, -}); - -describe('runTeardown', () => { - let state: FakeState; - - beforeEach(() => { - state = newFakeState(); - }); - - test('a named stage removes the state database on its own branch', async () => { - state.databases.push(stateDatabase('db-stage', 'br-stage')); - - await runTeardown( - { container: fakeContainer('proj-1', 'br-stage'), stage: 'staging' }, - { client: fakeClient(state), verify: ours }, - ); - - expect(state.deletedDatabaseIds).toEqual(['db-stage']); - }); - - test('production removes the state database on the default branch', async () => { - state.databases.push(stateDatabase('db-prod', 'br-default')); - - await runTeardown( - { container: fakeContainer('proj-1', undefined), stage: undefined }, - { client: fakeClient(state), verify: ours }, - ); - - expect(state.deletedDatabaseIds).toEqual(['db-prod']); - }); - - test('a named stage whose state database cannot be removed fails, naming the cause', async () => { - state.databases.push(stateDatabase('db-stage', 'br-stage')); - state.deleteStatus = 409; - - await expect( - runTeardown( - { container: fakeContainer('proj-1', 'br-stage'), stage: 'staging' }, - { client: fakeClient(state), verify: ours }, - ), - ).rejects.toThrow(/deploy-state database/); - }); - - test('production whose state database cannot be removed warns and does not fail the command', async () => { - state.databases.push(stateDatabase('db-prod', 'br-default')); - state.deleteStatus = 409; - const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); - - try { - await expect( - runTeardown( - { container: fakeContainer('proj-1', undefined), stage: undefined }, - { client: fakeClient(state), verify: ours }, - ), - ).resolves.toBeUndefined(); - - expect(warnSpy.mock.calls.flat().join(' ')).toMatch(/deploy-state database/); - } finally { - warnSpy.mockRestore(); - } - }); - - test('finding no state database succeeds, so a repeated destroy is a no-op', async () => { - await runTeardown( - { container: fakeContainer('proj-1', 'br-stage'), stage: 'staging' }, - { client: fakeClient(state), verify: ours }, - ); - - expect(state.deletedDatabaseIds).toEqual([]); - }); -}); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts index ec12b3a87..7513847a1 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/control/extension.ts @@ -39,7 +39,6 @@ import { RESERVED_PROVIDER_PARAMS } from '../provider-params.ts'; import { S3CredentialsProvider } from '../s3-credentials-resource.ts'; import type { ProviderParamEntry } from '../serializer.ts'; import { STREAMS_API_KEY } from '../streams-keys.ts'; -import { runTeardown } from '../teardown.ts'; /** * ADR-0031's registered provisioner for RPC_PEER_KEY: mints one `ServiceKey` @@ -344,10 +343,9 @@ export const prismaCloud = (opts: PrismaCloudOptions = {}): ExtensionDescriptor // in-shell names via a direct API POST — before any stack file or Alchemy. preflight: (input) => runPreflight(input), - // Destroy-time cleanup (ADR-0034): remove the stage's deploy-state - // database, once alchemy destroy has finished reading it and before the - // CLI removes the Branch/Project. - teardown: (input) => runTeardown(input), + // No teardown: deploy state lives behind the platform state API, scoped + // to the stage's Branch — deleting the Branch/Project deletes it + // platform-side. // Runs once per lowering, before any service: references the CLI-ensured // Project, with the poison DATABASE_URL variables written immediately so diff --git a/packages/1-prisma-cloud/1-extensions/target/src/teardown.ts b/packages/1-prisma-cloud/1-extensions/target/src/teardown.ts deleted file mode 100644 index 12b4d4e61..000000000 --- a/packages/1-prisma-cloud/1-extensions/target/src/teardown.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Destroy teardown (ADR-0034): after `alchemy destroy` has removed the stage's - * resources, remove the stage's deploy-state database — the store the destroy - * was reading until a moment ago, and the last thing Composer owns on the - * stage's Branch. - * - * Control-plane only (imported by control.ts → prisma-composer.config.ts); runs - * in the CLI parent, so it builds its own Management API client from env — the - * same credential path preflight uses. - */ -import type { TeardownInput } from '@internal/core/config'; -import { - fromEnv, - type ManagementApiClient, - ManagementClient, - managementClientLayer, -} from '@internal/lowering'; -import { - deleteStateDatabaseWith, - type OwnershipVerifier, - verifyOwnership, -} from '@internal/lowering/state'; -import * as Effect from 'effect/Effect'; -import * as Layer from 'effect/Layer'; -import { prismaCloudContainerOf } from './container.ts'; - -const tokenRequiredError = (): Error => - new Error('environment variable PRISMA_SERVICE_TOKEN is required for destroy teardown.'); - -async function managementClient(): Promise { - if ((process.env['PRISMA_SERVICE_TOKEN'] ?? '').length === 0) throw tokenRequiredError(); - return Effect.runPromise( - Effect.gen(function* () { - return yield* ManagementClient; - }).pipe(Effect.provide(managementClientLayer().pipe(Layer.provide(fromEnv())))), - ); -} - -/** - * The Prisma Cloud extension's `teardown`. Removes the stage's state database, - * with failure handling that differs by stage because the consequences do: - * - * - **Named stage: throw.** The Branch delete that follows would fail anyway — - * the platform refuses a Branch that still has a database attached — so - * failing here names the actual cause instead of a confusing symptom. - * - **Production: warn and continue.** Nothing blocks the Project delete on - * this; removing the database only stops production's state outliving - * production and holding a quota slot. That is a cleanup step, and a cleanup - * step must not fail the command. - * - * Accepts an injected client and ownership verifier for tests; otherwise - * builds a client from env and verifies against the real database. - */ -export async function runTeardown( - input: TeardownInput, - deps?: { readonly client?: ManagementApiClient; readonly verify?: OwnershipVerifier }, -): Promise { - const { projectId, branchId } = prismaCloudContainerOf(input.container); - const isNamedStage = branchId !== undefined; - try { - const client = deps?.client ?? (await managementClient()); - await Effect.runPromise( - deleteStateDatabaseWith( - { - projectId, - ...(branchId !== undefined ? { branchId } : {}), - }, - deps?.verify ?? verifyOwnership, - ).pipe(Effect.provideService(ManagementClient, client)), - ); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - if (isNamedStage) { - throw new Error(`Failed to delete the deploy-state database: ${reason}`); - } - console.warn(`Could not remove production's deploy-state database: ${reason}`); - } -} diff --git a/packages/9-public/composer-prisma-cloud/package.json b/packages/9-public/composer-prisma-cloud/package.json index bcc7c3ea5..a517917fb 100644 --- a/packages/9-public/composer-prisma-cloud/package.json +++ b/packages/9-public/composer-prisma-cloud/package.json @@ -49,7 +49,7 @@ "@prisma-next/postgres": "0.16.0", "@prisma-next/sql-contract": "0.16.0", "@prisma/composer": "workspace:0.6.0", - "@prisma/management-api-sdk": "^1.50.0", + "@prisma/management-api-sdk": "^1.57.0", "@standard-schema/spec": "^1.1.0", "alchemy": "2.0.0-beta.67", "arktype": "^2.2.3", @@ -57,7 +57,6 @@ "jose": "^6.1.3", "pathe": "^2.0.3", "pg": "8.22.0", - "postgres": "^3.4.9", "tsdown": "^0.22.7" }, "devDependencies": { diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 580715ee9..37904e2c0 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -42,8 +42,7 @@ "clipanion": "^3.2.1", "effect": "4.0.0-beta.103", "esbuild": "^0.28.1", - "postgres": "^3.4.9", - "@prisma/management-api-sdk": "^1.50.0" + "@prisma/management-api-sdk": "^1.57.0" }, "devDependencies": { "@internal/assemble": "workspace:0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa8844f0c..734256363 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -807,8 +807,8 @@ importers: specifier: workspace:0.6.0 version: link:../../../0-framework/0-foundation/foundation '@prisma/management-api-sdk': - specifier: ^1.50.0 - version: 1.50.0 + specifier: ^1.57.0 + version: 1.57.0 alchemy: specifier: 2.0.0-beta.67 version: 2.0.0-beta.67(patch_hash=b4f53c9c951c28d72ce7373ac88b38bd85718e114e5001fa7b2a8f7f869d6fac)(@effect/platform-bun@4.0.0-beta.103(effect@4.0.0-beta.103))(@effect/platform-node@4.0.0-beta.103(effect@4.0.0-beta.103)(ioredis@5.11.1))(@types/node@26.1.1)(@types/react@19.2.17)(effect@4.0.0-beta.103)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260704.1)(ws@8.21.1) @@ -1186,8 +1186,8 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))) '@prisma/management-api-sdk': - specifier: ^1.50.0 - version: 1.50.0 + specifier: ^1.57.0 + version: 1.57.0 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -1283,8 +1283,8 @@ importers: specifier: workspace:0.6.0 version: link:../composer '@prisma/management-api-sdk': - specifier: ^1.50.0 - version: 1.50.0 + specifier: ^1.57.0 + version: 1.57.0 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -2787,6 +2787,9 @@ packages: '@prisma/management-api-sdk@1.50.0': resolution: {integrity: sha512-xTg2xCKCwH1h6ZXzT8ieRmypINvFB5RIMAuWEuk2LpfsHUFs0RFuyRNw1CeYGVXnxWckkA8dWFQVLyjolZlDeA==} + '@prisma/management-api-sdk@1.57.0': + resolution: {integrity: sha512-NrTnL41BKj1XEs0sLsSUuxG6t4n1Qh1JldFnNpeLgTdTasfXYech4u8bCcfInxKkkPvFwDM759wpb9cSySK1Fw==} + '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} @@ -7269,6 +7272,10 @@ snapshots: dependencies: openapi-fetch: 0.14.0 + '@prisma/management-api-sdk@1.57.0': + dependencies: + openapi-fetch: 0.14.0 + '@prisma/query-plan-executor@7.2.0': {} '@prisma/streams-local@0.1.11': From 033147f7e7799ec4c0e81e7817a177c931aaf0d1 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 18:52:44 +0200 Subject: [PATCH 02/10] test(prisma-cloud): drop teardown.ts from the env-touch invariant The destroy teardown that read the shell token is gone with the interim state store; the invariant list follows. Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-extensions/target/src/__tests__/invariants.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index f62827c92..2ac4eabcc 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -122,7 +122,7 @@ describe('invariant 2: authoring imports stay lean (core + pack)', () => { }); describe('invariant 4: environment touches are confined to the config serializer, the control factory, and the container lifecycle', () => { - test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — the input document's deploy-shell default + boot read/secret lookup/generated-pointer lookup/stash pair, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), local-target/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), teardown.ts (shell token), compute.ts (exposes the resolved port as PORT), and testing.ts (bootstrapService's input-row + PORT writes, mirroring a deployed boot)", () => { + test("the process-env token appears only in serializer.ts (param read+stash, reserved-provider-param read+stash — the origin row rides that generic pair — the input document's deploy-shell default + boot read/secret lookup/generated-pointer lookup/stash pair, env-sourced param double-lookup, readOrigin's stash read), control/extension.ts's prismaCloud() (ADR-0017 — optional PRISMA_WORKSPACE_ID + optional PRISMA_REGION, neither required — local-dev spec § 5's lazy restructure; the CLI-fed deploy identity now arrives via ctx.container, never env), container.ts (PRISMA_WORKSPACE_ID + PRISMA_SERVICE_TOKEN, ADR-0038's container lifecycle), preflight.ts (shell token + fill-missing lookup), local-target/preflight.ts (dev's own shell-token read — the local-dev value-sourcing policy, ADR-0041), compute.ts (exposes the resolved port as PORT), and testing.ts (bootstrapService's input-row + PORT writes, mirroring a deployed boot)", () => { const sources = shippedSources(); expect(sources.length).toBeGreaterThan(0); @@ -139,7 +139,6 @@ describe('invariant 4: environment touches are confined to the config serializer { file: 'local-target/preflight.ts', count: 2 }, { file: 'preflight.ts', count: 2 }, { file: 'serializer.ts', count: 12 }, - { file: 'teardown.ts', count: 1 }, { file: 'testing.ts', count: 3 }, ]); }); From 77f6225f11b421ec5363a40f6f853ae9746f0e39 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 19:09:58 +0200 Subject: [PATCH 03/10] fix(prisma-cloud): address round-1 review of the hosted state layer - drop the architecture.config.json entry for the deleted teardown.ts - seed a replaced-status resource in the getReplacedResources test so it proves inclusion as well as exclusion - rewrite the CI Postgres-service comment to name the suites that still use it, and drop the stale "no Postgres connection" clause from the state layer test - register the Alchemy-State-Lease-Id header with effect's header redaction (Headers.CurrentRedactedNames), merged into the state layer's outputs, so a logged failed request renders the lease id as Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/ci.yml | 7 ++--- architecture.config.json | 6 ----- .../src/state/__tests__/layer.test.ts | 2 +- .../src/state/__tests__/state-api.test.ts | 27 ++++++++++++++++++- .../0-lowering/lowering/src/state/layer.ts | 3 ++- .../0-lowering/lowering/src/state/lease.ts | 15 +++++++++++ 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e38159a1..4adb6af8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,10 @@ jobs: test: name: Test runs-on: ubuntu-latest - # Backs the prisma-alchemy state/lock suites (packages/prisma-alchemy/src/state/__tests__). - # Without this, harness.ts finds no Postgres and those suites — the - # load-bearing coverage for the hosted state store — would silently skip. + # Backs the suites that need a real Postgres via STATE_TEST_DATABASE_URL: + # examples/auth and examples/storage (tests/pg-harness.ts) and the target + # extension's integration tests (src/__tests__/postgres-harness.ts). + # Without it those harnesses find no Postgres and the suites silently skip. services: postgres: image: postgres:16 diff --git a/architecture.config.json b/architecture.config.json index b50867ae1..a2155ceb7 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -306,12 +306,6 @@ "layer": "extensions", "plane": "control" }, - { - "glob": "packages/1-prisma-cloud/1-extensions/target/src/teardown.ts", - "domain": "prisma-cloud", - "layer": "extensions", - "plane": "control" - }, { "glob": "packages/1-prisma-cloud/1-extensions/target/src/container.ts", "domain": "prisma-cloud", diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts index f7d90f5a0..feec54453 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts @@ -15,7 +15,7 @@ describe('prismaStateLayer', () => { test('constructing the layer is inert — a projectId builds a Layer without touching the network', () => { // Layer.effect(...) only builds a lazy Effect description — no Management - // API call, no Postgres connection, no PRISMA_SERVICE_TOKEN read — until + // API call, no PRISMA_SERVICE_TOKEN read — until // something actually provides/runs the layer, which this test never does. expect(prismaStateLayer({ projectId: 'prj_1' })).toBeDefined(); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts index 758bd5926..0d67ee38d 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts @@ -4,6 +4,7 @@ import { Stack } from 'alchemy'; import { type CreatedResourceState, makeHttpStateStore, + type ReplacedResourceState, State, type StateService, } from 'alchemy/State'; @@ -12,6 +13,7 @@ import * as Fiber from 'effect/Fiber'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient'; +import * as Headers from 'effect/unstable/http/Headers'; import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; import { stateLayerAgainst } from '../layer.ts'; import { @@ -20,6 +22,7 @@ import { heartbeatDeployLease, LEASE_HEADER, type LeaseScope, + redactLeaseHeader, releaseDeployLease, } from '../lease.ts'; import { FakeStateApi } from './fake-state-api.ts'; @@ -162,13 +165,22 @@ describe('the stock state client against the platform state API', () => { test('getReplacedResources returns only replaced-status states', async () => { const service = await buildStore(await acquire()); const created = createdResource({ fqn: 'app/created' }); + const replaced: ReplacedResourceState = { + ...createdResource({ fqn: 'app/replaced' }), + status: 'replaced', + old: createdResource({ fqn: 'app/replaced' }), + deleteFirst: false, + }; await Effect.runPromise( service.set({ stack: STACK, stage: STAGE, fqn: created.fqn, value: created }), ); + await Effect.runPromise( + service.set({ stack: STACK, stage: STAGE, fqn: replaced.fqn, value: replaced }), + ); expect( await Effect.runPromise(service.getReplacedResources({ stack: STACK, stage: STAGE })), - ).toEqual([]); + ).toEqual([replaced]); }); test('losing the lease mid-run fails the next operation WITHOUT retries — exactly one request', async () => { @@ -190,6 +202,19 @@ describe('the stock state client against the platform state API', () => { }); describe('the deploy lease', () => { + test('the lease header renders redacted when the state layer’s redaction entry is in context', async () => { + const headers = Headers.fromInput({ [LEASE_HEADER]: 'lease-secret-1' }); + + const withEntry = await Effect.runPromise( + Effect.sync(() => JSON.stringify(headers)).pipe(Effect.provide(redactLeaseHeader)), + ); + const withoutEntry = await Effect.runPromise(Effect.sync(() => JSON.stringify(headers))); + + expect(withEntry).not.toContain('lease-secret-1'); + expect(withEntry).toContain(''); + expect(withoutEntry).toContain('lease-secret-1'); + }); + test('a second acquire for the same (stack, stage) fails fast naming the holder — no retry, no queueing', async () => { await acquire(); fake.requests.length = 0; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index d964dab97..c5a97e070 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -14,6 +14,7 @@ import { acquireDeployLease, heartbeatDeployLease, LEASE_HEADER, + redactLeaseHeader, releaseDeployLease, } from './lease.ts'; @@ -110,5 +111,5 @@ export const stateLayerAgainst = ( return Effect.succeed(service); }).pipe(Effect.provide(dependencies)), - ).pipe(Layer.orDie); + ).pipe(Layer.orDie, Layer.merge(redactLeaseHeader)); }; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts index 2b4aea3a3..5838a462f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts @@ -1,13 +1,28 @@ import * as os from 'node:os'; import type * as Duration from 'effect/Duration'; import * as Effect from 'effect/Effect'; +import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; +import * as Headers from 'effect/unstable/http/Headers'; import type { ManagementApiClient } from '../client.ts'; import { PrismaApiError } from '../http.ts'; /** The header every state operation and lease call carries. Its value is a capability token — never log it. */ export const LEASE_HEADER = 'Alchemy-State-Lease-Id'; +/** + * Adds the lease header to effect's redacted header names (alongside the + * defaults such as `authorization`), so a logged failed request renders the + * lease id as ``. Merged into the state layer's outputs. + */ +export const redactLeaseHeader: Layer.Layer = Layer.effect( + Headers.CurrentRedactedNames, + Effect.gen(function* () { + const names = yield* Headers.CurrentRedactedNames; + return [...names, LEASE_HEADER]; + }), +); + const LEASE_PATH = '/v1/projects/{projectId}/branches/{branchId}/alchemy-state/lease'; export interface LeaseScope { From 8262dd6256ff1e10cb711f29e560178252a706b6 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 19:15:09 +0200 Subject: [PATCH 04/10] test(prisma-cloud): prove the lease-header redaction through the real layer merge The round-2 test provided redactLeaseHeader directly, so deleting the Layer.merge line in layer.ts would have left it green. The new test renders the header inside runLayer (the full stateLayerAgainst context) and asserts ; the standalone test shrinks to the control showing effect's default redaction does not cover the header. Also rewraps a ragged comment in layer.test.ts. Signed-off-by: willbot Signed-off-by: Will Madden --- .../src/state/__tests__/layer.test.ts | 4 ++-- .../src/state/__tests__/state-api.test.ts | 21 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts index feec54453..caf3a88c1 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/layer.test.ts @@ -15,8 +15,8 @@ describe('prismaStateLayer', () => { test('constructing the layer is inert — a projectId builds a Layer without touching the network', () => { // Layer.effect(...) only builds a lazy Effect description — no Management - // API call, no PRISMA_SERVICE_TOKEN read — until - // something actually provides/runs the layer, which this test never does. + // API call, no PRISMA_SERVICE_TOKEN read — until something actually + // provides/runs the layer, which this test never does. expect(prismaStateLayer({ projectId: 'prj_1' })).toBeDefined(); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts index 0d67ee38d..1f3a80e37 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/state-api.test.ts @@ -22,7 +22,6 @@ import { heartbeatDeployLease, LEASE_HEADER, type LeaseScope, - redactLeaseHeader, releaseDeployLease, } from '../lease.ts'; import { FakeStateApi } from './fake-state-api.ts'; @@ -202,17 +201,12 @@ describe('the stock state client against the platform state API', () => { }); describe('the deploy lease', () => { - test('the lease header renders redacted when the state layer’s redaction entry is in context', async () => { + test('effect’s default redaction does NOT cover the lease header — the state layer must add it', async () => { const headers = Headers.fromInput({ [LEASE_HEADER]: 'lease-secret-1' }); - const withEntry = await Effect.runPromise( - Effect.sync(() => JSON.stringify(headers)).pipe(Effect.provide(redactLeaseHeader)), - ); - const withoutEntry = await Effect.runPromise(Effect.sync(() => JSON.stringify(headers))); + const rendered = await Effect.runPromise(Effect.sync(() => JSON.stringify(headers))); - expect(withEntry).not.toContain('lease-secret-1'); - expect(withEntry).toContain(''); - expect(withoutEntry).toContain('lease-secret-1'); + expect(rendered).toContain('lease-secret-1'); }); test('a second acquire for the same (stack, stage) fails fast naming the holder — no retry, no queueing', async () => { @@ -325,6 +319,15 @@ describe('prismaStateLayer against the platform state API', () => { expect(fake.liveLeaseIds()).toEqual([]); }); + test('the layer’s merged redaction entry hides the lease header from anything rendered in its context', async () => { + const rendered = await runLayer(() => + Effect.sync(() => JSON.stringify(Headers.fromInput({ [LEASE_HEADER]: 'lease-secret-1' }))), + ); + + expect(rendered).not.toContain('lease-secret-1'); + expect(rendered).toContain(''); + }); + test('a concurrent second deploy of the same stage fails immediately, naming the holder', async () => { // A live deploy holds the lease, acquired as another operator. const holderResponse = await fetch( From 8504f80efcd00058994c633cc2fb9863331856b7 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 19:23:55 +0200 Subject: [PATCH 05/10] =?UTF-8?q?docs(adr):=20ADR-0045=20=E2=80=94=20deplo?= =?UTF-8?q?y=20state=20lives=20behind=20the=20platform=20state=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the decision D1 implemented: state behind the Management API (alchemy's stock HttpStateApi wire contract per Branch, stock client), with a server-side per-(stack, stage) deploy lease. Supersedes ADR-0010 (advisory lock -> lease, fail-fast contention preserved) and the storage half of ADR-0034 (Branch scoping and lifetime stand); closes ADR-0012 as obsolete via its own pick-up trigger. Banners on all three; index updated. Prose docs updated to the new story: layering.md's provisioning-state spectrum (the platform-hosted step is now real; server-side runs is the remaining future step), deploy-cli.md, the deploying guide (state paragraph, destroy order, a combined legacy-upgrade section covering both older store generations and the up-front refusal), the glossary state store entry, and gotchas.md's reference to the deleted bootstrap code. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/03-domain-model/glossary.md | 8 +- docs/design/03-domain-model/layering.md | 47 +++-- docs/design/10-domains/deploy-cli.md | 14 +- ...10-deploys-hold-a-session-advisory-lock.md | 7 + ...012-the-state-store-speaks-sql-directly.md | 6 + ...-deploy-state-lives-in-the-stage-branch.md | 7 + ...ate-lives-behind-the-platform-state-api.md | 168 ++++++++++++++++++ docs/design/90-decisions/README.md | 7 +- docs/guides/deploying.md | 80 ++++----- gotchas.md | 4 +- 10 files changed, 265 insertions(+), 83 deletions(-) create mode 100644 docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 988a2d807..14a591adb 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -398,10 +398,10 @@ is in `layering.md`; this is the term-by-term catalogue. - **Stage** — an isolated instance of a Stack (`dev`, `staging`, `prod`, `pr-42`) with its own state and physical names. `→` **Environment**. - **State store** — persists each Resource's state per stack+stage so the engine - can diff the next deploy. `prismaCloud()` defaults every deploy to a - Prisma-hosted, workspace-scoped store (`@internal/lowering/state`); an - explicit state layer always overrides it. Control-plane infra, never a - topology node. + can diff the next deploy. `prismaCloud()` defaults every deploy to + platform-hosted state behind the Management API, scoped to the stage's + Branch (`@internal/lowering/state`, ADR-0045); an explicit state layer + always overrides it. Control-plane infra, never a topology node. ### Alchemy — engine verbs (provider lifecycle) diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index 209210a3c..f9c0735a0 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -123,37 +123,30 @@ reproduce-in-the-emulator goal (see `../00-purpose/goals.md`). Provisioning runs through **Alchemy's engine**, invoked from the client or a privileged CD environment (see claim 3). The engine keeps a **state store** — the source of truth for what's provisioned. State sits on a spectrum from -local, to branch-hosted, to eventually platform-run: +local, to platform-hosted (where we are), to eventually platform-run: - **Local** — Alchemy's local or Cloudflare-backed state. Fine for a solo developer; nothing else needs to see it. -- **Branch-hosted** — a `StateService` implementation - (`@internal/lowering/state`) backed by a framework-owned Prisma Postgres - database in each stage's Branch of the app's own Project (ADR-0034), - native to the Workspace → Project → Branch hierarchy - (Pulumi/Terraform-Cloud-style hosted state, without the BYO-state - bootstrap). Bootstrap is automatic: the Management API finds or creates - the stage's state database from the container ids the CLI already - resolves, so a deployer needs nothing beyond the service token and - workspace id it already has, and the state's lifetime is the - environment's — deleting the Branch or Project deletes it. Concurrency is - a per-`(stack, stage)` advisory lock, so two deployers can never race the - same stack. `prismaCloud()` supplies this as the default deploy state for - every service and Module; an explicit state layer always overrides it. - This is framework-owned operational infrastructure, not a user-topology - Resource — ambient per stage, never declared by a Module (the containers - it lives in are created before the engine runs, which sidesteps the - chicken-and-egg of provisioning the store itself). Like hosted-state - backends generally, it also holds state for the user's BYO resources in - other clouds. -- **Server-side runs** — the platform executes the apply loop itself - (git-push-style deploys). Once state is platform-hosted, moving the engine - server-side is incremental — the same evolution Pulumi/Terraform Cloud - followed. This step's platform surface is implementing Alchemy's own HTTP - `StateApi` (bearer auth → workspace RBAC) as a Management API endpoint; once - it exists, the branch-hosted store's visible databases disappear and the - platform can answer "what's provisioned in this project" natively (the +- **Platform-hosted** — the Management API implements Alchemy's own HTTP + `StateApi` wire contract per Branch of the app's own Project + (`…/branches/{branchId}/alchemy-state`, ADR-0045), and the framework's + state layer (`@internal/lowering/state`) is Alchemy's stock HTTP client + pointed at it — Pulumi/Terraform-Cloud-style hosted state, native to the + Workspace → Project → Branch hierarchy, with no BYO-state bootstrap and no + visible state database. A deployer needs nothing beyond the service token + it already has, and the state's lifetime is the environment's — deleting + the Branch or Project deletes it. Concurrency is a server-side + per-`(stack, stage)` deploy lease held around the run, so two deployers + can never race the same stack. `prismaCloud()` supplies this as the + default deploy state for every service and Module; an explicit state layer + always overrides it. Like hosted-state backends generally, it also holds + state for the user's BYO resources in other clouds, and it lets the + platform answer "what's provisioned in this project" natively (the platform side of the inspectable-topology goal). +- **Server-side runs** — the platform executes the apply loop itself + (git-push-style deploys). With state already platform-hosted, moving the + engine server-side is incremental — the same evolution Pulumi/Terraform + Cloud followed. ## Open questions diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index ffa78b370..36e5b34bf 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -136,20 +136,20 @@ targets **production**; `--stage ` targets a **named stage**. find-only (no container is ever created); after `alchemy destroy` succeeds and after every extension's `teardown` has run, the CLI removes each resolved container. That two-loop order — every teardown, then every - removal — is what keeps a stage's deploy state deleted before its - container goes. + removal — is what guarantees every extension's teardown runs against a + still-live container. **Prisma Cloud's own containers** are its app's **Project** and, for a named stage, that stage's **Branch** — found by name, created if absent on deploy, -never created on destroy; each stage's deploy state lives in a -framework-owned `prisma-composer-state` database attached to its Branch -(production's on the Project's implicit default Branch). See +never created on destroy; each stage's deploy state lives behind the platform +state API, scoped to its Branch (production's to the Project's implicit +default Branch). See [ADR-0023](../90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) (App = one Project, Stage = Branch), [ADR-0024](../90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) (stage resolution mechanics), and -[ADR-0034](../90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md) -(deploy state lives on the stage's Branch). +[ADR-0045](../90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) +(deploy state behind the platform state API, per Branch). ## Build ownership diff --git a/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md b/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md index 3e4113487..8b790aa36 100644 --- a/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md +++ b/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md @@ -1,5 +1,12 @@ # ADR-0010: Deploys hold a session advisory lock per stack and stage +> Superseded by +> [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md): +> the per-`(stack, stage)` deploy lease is now held server-side against the +> platform state API (TTL + heartbeat), not as a Postgres session advisory +> lock. The fail-fast contention behavior — refuse immediately, name the +> holder, never queue — is preserved. + ## Decision A deploy acquires a Postgres session advisory lock on the stage's hosted diff --git a/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md b/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md index e5a02476e..53f80a0be 100644 --- a/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md +++ b/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md @@ -1,5 +1,11 @@ # ADR-0012: The state store speaks SQL directly; Prisma Next adoption is deferred +> Closed as obsolete by +> [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md), +> via this record's own pick-up trigger: the platform-side state API landed, +> the SQL store is gone, and composer speaks the API through Alchemy's stock +> HTTP client — there is no store data layer left to adopt Prisma Next for. + ## Decision The hosted state store's data access is hand-written SQL over a plain Postgres diff --git a/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md b/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md index df5b343ce..405fea57c 100644 --- a/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md +++ b/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md @@ -1,5 +1,12 @@ # ADR-0034: Deploy state lives in a framework-owned database in the stage's Branch +> Superseded in part by +> [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md): +> the container and lifetime reasoning stands — state is still a child of the +> stage's Branch, deleted with it — but the storage mechanism is replaced. +> State lives behind the platform state API; the visible per-stage +> `prisma-composer-state` database is gone. + ## Decision Each stage's deploy state — the provisioning engine's record of what exists in diff --git a/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md new file mode 100644 index 000000000..3b780c739 --- /dev/null +++ b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md @@ -0,0 +1,168 @@ +# ADR-0045: Deploy state lives behind the platform state API; deploys hold a server-side lease + +## Decision + +Each stage's deploy state — the provisioning engine's record of what exists in +the cloud — lives behind the Management API. The platform implements Alchemy's +stock `HttpStateApi` wire contract verbatim under +`/v1/projects/{projectId}/branches/{branchId}/alchemy-state`, and composer's +state layer is Alchemy's own stock HTTP client (`makeHttpStateStore`) pointed +at it — no store code of our own. Around every run the deploy holds a +**server-side lease** per `(stack, stage)`; every state operation carries the +lease id and the server rejects any operation without a live lease. + +``` +deploy run + ├─ POST …/alchemy-state/lease acquire; 409 → fail fast, + │ error names the current holder + ├─ PATCH …/alchemy-state/lease heartbeat every 20s (TTL 60s) + ├─ * …/alchemy-state/state/… the stock HttpStateApi wire + │ contract; every call carries + │ Alchemy-State-Lease-Id + └─ DELETE …/alchemy-state/lease release (scoped finalizer) +``` + +This supersedes +[ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — the Postgres +session advisory lock becomes this lease; the fail-fast contention behavior is +preserved — and the storage half of +[ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md): state is still +a child of the stage's Branch with exactly the environment's lifetime, but the +visible per-stage `prisma-composer-state` database disappears. It closes +[ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) as obsolete via +that record's own pick-up trigger ("the platform-side state API lands — this +store shrinks to a client or disappears"). +[ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) is unchanged: +the Prisma Cloud target still supplies this layer as the deploy's state store. + +## Reasoning + +The end state was recorded twice before it existed. ADR-0009 named a +platform-side state API as where hosted state ultimately belongs; ADR-0034 +called its own database store the proof of the *right shape* for that API — +state scoped as a child of the Branch, cascading on delete. The platform now +implements that API, so composer stops carrying the interim machinery: the +per-stage database, its bootstrap/ownership-marker/connection-minting code, +the SQL store, and the advisory lock with its liveness checker. + +Because the server speaks Alchemy's stock wire contract verbatim, the client +side is not ours to write. Composer builds Alchemy's own `makeHttpStateStore` +with the scope's URL, the workspace service token, and one request transform +that adds the lease header. There is no SQL, no driver, no schema, and no +store test suite of our own to maintain — the contract is Alchemy's, proven by +Alchemy. What remains in composer is scope resolution (a URL needs a concrete +`branchId`; production resolves the Project's default Branch), the lease +client, and operator-facing error wrapping. + +The lease replaces the advisory lock because the lock's substrate is gone. +ADR-0010 chose a Postgres session lock precisely because it was a lease the +store's own database provided for free — bound to a connection, released on +crash. With no database there is no session, so the lease moves to where the +state now lives: the server. A deploy acquires it before the first state +operation (60-second TTL by default; the server clamps requested TTLs to +30–300 seconds), heartbeats it on a forked fiber every 20 seconds, and +releases it as a finalizer. Contention keeps ADR-0010's exact behavior: a +second deploy of the same `(stack, stage)` fails immediately with the server's +message naming the current holder — it never queues. + +Enforcement also moves server-side, which deletes a whole client subsystem. +Under ADR-0010 the client had to *notice* a lost lock, and its liveness +checker existed to work around driver crash behavior. Now every state +operation is checked by the server: without a live lease it fails with 409 — a +status the stock client treats as fatal (it retries transient failures, never +409) — so a run that loses its lease stops within at most one further request. +No client-side liveness check exists at all. + +What does not change is the addressing ADR-0034 fought for. State rows are +children of the Branch: delete the Branch (CLI, Console, any platform surface) +and the stage's state goes with it; production's state sits on the implicit +default Branch. Auth is unchanged too — the same workspace service token the +deploy already holds, with no minted per-run connection strings and no +ownership markers, because there is no database to prove ownership of. On the +server the rows are encrypted at rest under the per-project data-encryption +key, and the server enforces bounds on key lengths (stack, stage, fqn), so +malformed scopes fail loudly at the API rather than landing in storage. + +One naming wrinkle is deliberate: the store still registers itself with +Alchemy's telemetry as `id: 'prisma-postgres'`. That slug identifies the state +*service* in metrics and spans (`alchemy.state_store.id`), and changing it +would split every dashboard series keyed on it. The slug outlives the database +it once described; it now just means "Prisma-hosted state". + +The cutover carries no migration, on ADR-0034's own precedent. A stage +deployed under the database store starts from empty API state, and deploying +over it blind would recreate every resource and die in `already_exists` +failures. So the state layer keeps its empty-scope check, re-pointed: after +acquiring the lease, if the API holds no resources for `(stack, stage)` but +the Branch already runs live Compute apps, the deploy refuses with +instructions — destroy the stage with the previous composer version, or +delete the stage's Branch (the Project, for production), then redeploy fresh. +Legacy `prisma-composer-state` databases are never read and never deleted by +this version: deleting a stage's Branch removes that stage's database +platform-side, while production's lingers (one quota slot, no money) until +deleted by hand — a documented cleanup, not an automated one. + +## Consequences + +- **No visible state database.** The Console shows only the user's own + databases; the quota slot each stage's store consumed (ADR-0034's standing + consequence) is returned, and the delete-the-state-database-by-hand footgun + disappears with it. +- **Crash recovery trades instant for bounded.** The advisory lock freed the + moment a crashed deploy's connection dropped; a crashed deploy's lease now + blocks the stage until its TTL expires — up to 60 seconds. Accepted: rare + case, small bound, and the retrying operator sees who holds the lease. +- **Contention behavior is preserved.** Fail fast, never queue, error names + the holder. A `--wait` affordance can still layer over the same lease later + without changing its semantics. +- **Lease loss is detected server-side within one extra request**, instead of + within a client-side trust window. The stock client's fatal treatment of + 409 is what makes this hold; if the client's retry policy ever changes, + this property must be re-verified. +- **The routes are experimental.** The platform may still move them; composer + pins its Alchemy version, so a coordinated change is a normal dependency + bump, not a live break. +- **No migration.** Legacy stages refuse to deploy until destroyed or + deleted (see the deploying guide); their state databases are cleaned up by + Branch deletion or by hand, never by this version's code. +- **Platform-side teardown still covers platform resources only.** State can + track resources outside Prisma Cloud; deleting the Branch deletes the only + record of them. Same documented limitation as ADR-0034, same shape. +- **Telemetry continuity.** The `prisma-postgres` state-store slug persists + across the storage change; series keyed on it read through the cutover. + +## Alternatives considered + +- **Keep the database store** — rejected: two ADRs recorded the API as the end + state, the API now exists, and keeping both means maintaining a bespoke + store, its lock, and its proof suite alongside a stock client. +- **A composer-written API client** — rejected: the server implements + Alchemy's contract verbatim, so the stock client is the contract; a bespoke + client could only drift from it. +- **Port ADR-0010's liveness checker to the API** — rejected: the server + checks the lease on every operation; a client-side pre-check would add a + round-trip to re-derive what the next request reports anyway. +- **Migrate legacy state into the API** — rejected: destroy-then-redeploy is + the recorded precedent (ADR-0034), the affected population is pre-GA, and + migration tooling would have to be proven against every legacy store + generation for a one-time event. +- **Queue on lease contention** — rejected again for the reasons in ADR-0010: + a hanging deploy is worse than a clear refusal; waiting can be added as an + explicit flag later. + +## Related + +- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) / + [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — the two + prior stores; both named this API as the end state. ADR-0034's + Branch-scoping and lifetime reasoning carries over unchanged. +- [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — the advisory + lock this lease supersedes; its contention UX survives. +- [ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) — unchanged: + the target supplies this layer. +- [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) — closed as + obsolete; its pick-up trigger fired. +- prisma/pdp-control-plane #4816 (schema) and #4817 (API) — the server + implementation of the state routes and the lease. +- [`../03-domain-model/layering.md`](../03-domain-model/layering.md) — the + provisioning-state spectrum this advances. diff --git a/docs/design/90-decisions/README.md b/docs/design/90-decisions/README.md index 41a1aaaff..a839f1f7e 100644 --- a/docs/design/90-decisions/README.md +++ b/docs/design/90-decisions/README.md @@ -31,9 +31,9 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0007](ADR-0007-deploy-drives-alchemy-through-a-generated-stack-file.md) — Deploy drives Alchemy through a generated, inspectable stack file. - [ADR-0008](ADR-0008-wrapper-inlines-everything-except-runtime-builtins.md) — The boot wrapper inlines everything except runtime built-ins. - [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) — Deploy state is hosted in the workspace, not in local files. *(Superseded by ADR-0034: still hosted, now per-stage in the app's own Project.)* -- [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — Deploys hold a session advisory lock per stack and stage. +- [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — Deploys hold a session advisory lock per stack and stage. *(Superseded by ADR-0045: the lease is server-side now; the fail-fast contention behavior survives.)* - [ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) — Targets supply the deploy state layer; core owns no default. -- [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) — The state store speaks SQL directly; Prisma Next adoption is deferred. +- [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) — The state store speaks SQL directly; Prisma Next adoption is deferred. *(Closed as obsolete by ADR-0045, via its own pick-up trigger: the platform state API landed and the SQL store is gone.)* - [ADR-0013](ADR-0013-resources-are-provisioned-by-modules-deps-are-declarations.md) — Resources are provisioned by modules; dependencies are uniform contract-checked slots. - [ADR-0014](ADR-0014-one-authoring-primitive.md) — Establishes one authoring primitive with no separate `app()` (the App is the outermost Module). Its framework, package, and CLI names are superseded by ADR-0026 (**Prisma Composer**) and its unit noun by ADR-0025 (**Module**). - [ADR-0015](ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md) — Dependencies resolve to bindings (a client for protocol-owned kinds, typed config for resources); clients are constructed app-side. @@ -55,7 +55,7 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0031](ADR-0031-provisioned-param-values-are-a-need-resolved-through-a-target-registry.md) — A framework-minted param value is an opaque, branded **provisioning need** (not a named facet on the param): core forwards it and resolves its brand against the deploy target's `provisions` registry, failing loudly on a miss. The provisioner owns all mint/size/stability/rotation policy; core's surface stays one field. Resolves against the consumer's extension; cross-extension edges fail closed. *(Proposed)* - [ADR-0032](ADR-0032-params-bind-at-provision-env-sourcing-is-a-target-source.md) — A param can be bound at `provision()` — a schema-validated literal (beats `default`) or a target-owned source (`envParam('NAME')`, mirroring ADR-0029's need/source split): a pointer row on the wire, boot double-lookup, the raw string handed to the param's own schema, read through `config()` unredacted; preflight covers the names like secrets. *(Partially superseded by ADR-0042: provision-time binding and `envParam` sourcing survive as binding mechanics; the `config()` read and per-key pointer rows become one document row.)* - [ADR-0033](ADR-0033-lowering-types-are-defined-by-their-readers.md) — Every value in the lowering pipeline is typed by the code that reads it, not the code that writes it, retiring the shared `LoweredNode` record: a descriptor types the values it passes between its own phases (`ServiceLowering`); the application hook's product reaches core as `unknown` and its own extension narrows it with a guard; a node's values for the nodes downstream are name-keyed `WiringOutputs`, resolved against the consumer's connection declaration. The lowering loop is the only party that knows which output feeds which input. Records the alchemy execution facts (a value passed between phases legitimately holds an unresolved `Output`), that the heterogeneous registry's type safety rests on the loop rather than the compiler, and that an unchecked claim is acceptable only when it is named, justified, and singular. -- [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — Deploy state lives in a framework-owned `prisma-composer-state` database in the stage's own Branch (production: the implicit default Branch). State has the environment's lifetime: platform-side Branch/Project deletion cleans it up with no framework involvement; the CLI deletes it last-among-members on destroy. Supersedes ADR-0009's workspace-level store. +- [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — Deploy state lives in a framework-owned `prisma-composer-state` database in the stage's own Branch (production: the implicit default Branch). State has the environment's lifetime: platform-side Branch/Project deletion cleans it up with no framework involvement; the CLI deletes it last-among-members on destroy. Supersedes ADR-0009's workspace-level store. *(Superseded in part by ADR-0045: the Branch scoping and lifetime stand; the per-stage database is replaced by the platform state API.)* - [ADR-0035](ADR-0035-public-entrypoints-live-in-src-exports.md) — Public entrypoints live in `src/exports/` (one file per subpath; internals stay at the `src/` root); `@internal/tsdown-config` generates `package.json#exports` from object-named entries where safe, with two deliberate exceptions kept hand-maintained (the multi-pass `cron`/`storage`/`streams`, and the two published packages). Completes ADR-0028. - [ADR-0036](ADR-0036-the-rpc-kind-is-named-service-rpc.md) — The RPC kind is named **service RPC**: subpath `@prisma/composer/service-rpc`, unchanged call-site names (`rpc()`, `contract()`, `serve()`), kind brand stays `'rpc'`. Scope recorded in connection-contracts.md: edges internal to the application topology, agent-generatable by design — not an application API layer, not general distributed-systems infrastructure. - [ADR-0037](ADR-0037-service-rpc-calls-carry-an-idempotency-key.md) — The generated service RPC client carries an `Idempotency-Key` on every call — one per logical call, reused across a bounded retry — and the provider deduplicates on it: one call per key, replaying completed 2xx/4xx answers (never 5xx) from a bounded in-process store. A keyless request (a hand-rolled or older caller) is served once without deduplication rather than rejected. Retrying is permanent protocol behavior, not a platform workaround, and there is no per-method opt-in — a flag would be an unverifiable claim. Handlers may read the key via an optional third argument for their own durable exactly-once. @@ -66,3 +66,4 @@ _Earlier drafts (ADR-0001, ADR-0002) were retired as the high-level design settl - [ADR-0042](ADR-0042-service-input-is-one-standard-schema.md) — A compute service declares its entire incoming configuration — config and secrets together — as one Standard Schema (`input`), read back through one typed accessor; `params`/`secrets` and `config()`/`secrets()` are replaced. The framework never introspects the schema (validate-only, per the spec): the operator's binding is the traversable structure (sourcing: literals, `envParam`, `envSecret`), the schema is the black-box judge of legality (invoked at deploy over the resolved binding with secrets as opaque `SecretString` boxes, and again at boot), and secretness is a leaf *type* enforced by validation in both directions. The wire format is one self-describing JSON document row per service with `$secret` pointers to platform variables; an env-bound key whose variable is unset resolves to key-omitted and the schema arbitrates absence — subsuming optional secrets and conditional config (`stripeId` only when `stripeEnabled`) without a framework DSL. - [ADR-0043](ADR-0043-the-control-subpath-is-the-programmatic-deploy-surface.md) — `@prisma/composer/control` is the programmatic deploy surface: typed `deploy`/`destroy`/`dev`/`log` operations (structured inputs/results, no argv/console/exit) implemented in `@internal/cli`'s `src/operations/` and re-exported per ADR-0035; the CLI is a thin renderer over them. The entry's static graph stays import-light — each operation lazily imports its executor, so importing the subpath executes nothing, and a tree that cannot load the deploy stack surfaces as a structured `pipeline` failure — and `PRISMA_COMPOSER_DEPLOYMENT_RESULT_FILE` carries the deploy result across the process boundary: the alchemy child's report hook writes a serializable `DeploymentSummary` to the named file, the operation reads it back best-effort (absent/malformed = undefined summary, never a failure). Distinct from an extension's ADR-0017 `/control` entry. - [ADR-0044](ADR-0044-errors-are-structural-envelopes-with-dotted-namespace-codes.md) — Errors are structural envelopes with dotted `NAMESPACE.SUBCODE` codes (the shared prisma/prisma foundation, duplicated pending extraction): structured at origin with why/fix splits, no catch-all codes, bugs carry no code (exit 1 + report hint), recognition is structural (`CliStructuredError.is()`), operation results ride the shared `Result` `ok` discriminator, expected failures exit 2 — with the alchemy child-status passthrough as the documented exception. +- [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) — Deploy state lives behind the platform state API (the Management API implements Alchemy's stock `HttpStateApi` wire contract per Branch; composer's state layer is Alchemy's stock HTTP client), and deploys hold a server-side per-`(stack, stage)` lease (TTL 60s, heartbeated, released on exit; contention fails fast naming the holder; state operations without a live lease fail 409). Supersedes ADR-0010 (lock → lease) and the storage half of ADR-0034 (Branch scoping and lifetime stand; the visible per-stage database is gone); closes ADR-0012 as obsolete. No migration: legacy stages are refused until destroyed or deleted. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 947e61938..ead666182 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -38,14 +38,14 @@ turbo run build && prisma-composer deploy module.ts Deploy state (what's already provisioned, so re-deploys diff instead of recreate) is stored with the environment it describes, not on your machine — -that's the `prismaState()` line in `prisma-composer.config.ts`. Each -environment keeps a small framework-owned database named -`prisma-composer-state` inside the app's Project, attached to that -environment's Branch. Everyone deploying the app shares it, your laptop and -CI see the same world, and two concurrent deploys of the same environment -lock each other out instead of corrupting it. Destroying or deleting an -environment removes its state with it — don't delete the state database by -hand, or the next deploy will re-provision from scratch. +that's the `prismaState()` line in `prisma-composer.config.ts`. The platform +hosts each environment's state behind its API, scoped to that environment's +Branch inside the app's Project; nothing extra shows up in the Console. +Everyone deploying the app shares it, your laptop and CI see the same world, +and two concurrent deploys of the same environment lock each other out +instead of corrupting it: the second one fails immediately with a message +naming who holds the deploy lease. Destroying or deleting an environment +removes its state with it. ## Production and stages @@ -117,9 +117,9 @@ prisma-composer destroy module.ts --production # production's resources ``` `--stage` and `--production` together is an error too. Destroying a stage -removes its resources, then its state database, then deletes its Branch; -destroying production removes the resources and its state database, but the -production Branch itself always survives. +removes its resources, then deletes its Branch — and the Branch takes the +stage's deploy state with it; destroying production removes the resources, +but the production Branch itself always survives. Destroy never creates: tearing down a stage that was never deployed fails with "nothing deployed" rather than provisioning one first. @@ -264,43 +264,43 @@ When something misbehaves in ways these don't explain, check [`gotchas.md`](../../gotchas.md) at the repo root — the catalogue of platform footguns with diagnoses, kept current as we hit them. -## Upgrading from workspace-hosted state +## Upgrading from an older state store -Older framework versions kept deploy state in a workspace-level -`prisma-composer-state` project instead of inside each environment. There is -no automated migration — a deploy under the new store starts from empty state -and would re-provision resources it can't see. Cut over per app: +Older framework versions stored deploy state differently: first in a +workspace-level `prisma-composer-state` project, later in a small +`prisma-composer-state` database on each environment's Branch. The current +version stores state behind the platform's API and never reads either legacy +store — there is no automated migration. The cutover is the same for both +generations: destroy, upgrade, redeploy. + +Deploying over a live legacy environment is refused up front. The deploy +finds no API-hosted state but sees apps already running on the Branch, and +stops with an error saying the stage predates the platform state API — +instead of blindly recreating every resource and failing halfway. Cut over +per app: 1. On the **old** framework version, destroy every environment: each - `--stage`, then `--production`. + `--stage`, then `--production`. (Equivalent: delete the stage's Branch — + or the whole Project, for production — in the Console or via the + Management API.) 2. Upgrade the framework packages. -3. Deploy again — each environment provisions fresh state in its own Branch. -4. Delete the workspace-level `prisma-composer-state` project from the - Console whenever convenient; nothing reads it after the upgrade. - -### If you upgraded before destroying - -A deploy on the new version starts from empty state, finds the resources the -old state was tracking, and refuses to touch them: - -``` -PrismaApiError: {"error":{"code":"app:already_exists", ...}} -EnvironmentVariable "COMPOSER_..." exists but is untracked in this deploy -state — refusing to overwrite a reserved COMPOSER_ key. -``` - -The old state cannot be read back. Recover by removing the leftovers so the -next deploy recreates everything under fresh state — either: - -- Delete the app's Project in the Console (everything in it goes: apps, - databases, environment variables), then deploy again. Simplest, and right - whenever the Project holds nothing you created outside the framework. -- Or downgrade the framework packages, run the destroys from step 1, upgrade - again, and deploy. +3. Deploy again — each environment starts fresh, hosted behind the platform + state API. Recreated apps get new generated URLs; anything pointing at the old ones needs updating. +Legacy leftovers are inert and safe to remove whenever convenient — nothing +reads them after the upgrade, and each costs only a database quota slot: + +- Branch-hosted generation: destroying on the old version already removed + the environment's `prisma-composer-state` database. If you skipped that + and deleted Branches by hand instead, each Branch took its database with + it — but production's, on the default Branch, survives: delete it in the + Console. +- Workspace-hosted generation: delete the workspace-level + `prisma-composer-state` project from the Console. + ## Driving deploys from code Everything the CLI does is also callable in-process, from diff --git a/gotchas.md b/gotchas.md index 8d18f6aba..833fb1ddf 100644 --- a/gotchas.md +++ b/gotchas.md @@ -474,7 +474,7 @@ Retry the same DSN a few seconds later and the real, permanent cause appears: Failed to identify your database: Your account has restrictions: planLimitReached ``` -The Management API is no help: the project and database both read `status: "ready"`. Through Prisma Composer's hosted state store the failure surfaces two layers from its cause — `HostedStateBootstrapError: … finding/creating the prisma-composer-state project — ownership verification failed: … not configured correctly yet` — which points at the state store's own bootstrap rather than at the account. +The Management API is no help: the project and database both read `status: "ready"`. At the time, Prisma Composer's state store was itself a workspace database (since replaced by the platform state API, ADR-0045), so the failure surfaced two layers from its cause — `HostedStateBootstrapError: … finding/creating the prisma-composer-state project — ownership verification failed: … not configured correctly yet` — pointing at the state store's bootstrap rather than at the account. Any database connection in the workspace hits the same wall. **Cause.** Two unrelated conditions share one error prefix and the first one wins on a cold database. The generic "not configured correctly yet" text is the same message [FT-5226](https://linear.app/prisma-company/issue/FT-5226) documents for a cold upstream; the plan restriction is only reported once the upstream is warm. The two want **opposite** responses — FT-5226 says retry, `planLimitReached` says stop and reclaim a database — so the message that arrives first tells you to do the wrong thing. Worse, any bounded cold-start retry (the documented FT-5226 workaround, and what deploy-time code does) spends its whole budget re-showing the misleading message and then reports the misleading message. @@ -491,7 +491,7 @@ The Management API is no help: the project and database both read `status: "read - Upstream: [FT-5227](https://linear.app/prisma-company/issue/FT-5227/planlimitreached-is-masked-by-the-cold-start-not-configured-correctly) - Related: [FT-5226](https://linear.app/prisma-company/issue/FT-5226) (the cold-start message this is confused with, and whose retry workaround this defeats), [PRO-212](https://linear.app/prisma-company/issue/PRO-212) (the same API's habit of reporting the wrong field) -- Surfaced through: [`packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts`](packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts) (`verifyOwnership`) +- Surfaced through: the state store's former database bootstrap (`verifyOwnership` in `packages/1-prisma-cloud/0-lowering/lowering/src/state/bootstrap.ts`, removed when state moved behind the platform state API — ADR-0045). The platform behavior itself is unchanged. --- From 76b26daa8d1833c0cd9a44fa9c43b73fd05926af Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 19:29:18 +0200 Subject: [PATCH 06/10] docs(adr): ADR-0045 round-2 editorial fixes - admit the wire-contract fake and its drift obligation instead of claiming no test suite of our own - state the honest experimental-routes consequence: a route move breaks installed versions until users upgrade; the /version probe detects contract drift but does not prevent the break - attribute the TTL clamp and the encryption/key-length facts inline to the server PRs, the only place a reader can check them Signed-off-by: willbot Signed-off-by: Will Madden --- ...ate-lives-behind-the-platform-state-api.md | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md index 3b780c739..55478f84f 100644 --- a/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md +++ b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md @@ -49,10 +49,14 @@ Because the server speaks Alchemy's stock wire contract verbatim, the client side is not ours to write. Composer builds Alchemy's own `makeHttpStateStore` with the scope's URL, the workspace service token, and one request transform that adds the lease header. There is no SQL, no driver, no schema, and no -store test suite of our own to maintain — the contract is Alchemy's, proven by -Alchemy. What remains in composer is scope resolution (a URL needs a concrete -`branchId`; production resolves the Project's default Branch), the lease -client, and operator-facing error wrapping. +store of our own whose storage correctness we must prove — the contract is +Alchemy's, proven by Alchemy. Composer does keep an in-process fake of the +wire contract to test its own wiring (the lease lifecycle, the guard, the +client pointed at our URL shape); a fake can drift from the contract it +mirrors and must track it. What remains in composer beyond that is scope +resolution (a URL needs a concrete `branchId`; production resolves the +Project's default Branch), the lease client, and operator-facing error +wrapping. The lease replaces the advisory lock because the lock's substrate is gone. ADR-0010 chose a Postgres session lock precisely because it was a lease the @@ -60,8 +64,9 @@ store's own database provided for free — bound to a connection, released on crash. With no database there is no session, so the lease moves to where the state now lives: the server. A deploy acquires it before the first state operation (60-second TTL by default; the server clamps requested TTLs to -30–300 seconds), heartbeats it on a forked fiber every 20 seconds, and -releases it as a finalizer. Contention keeps ADR-0010's exact behavior: a +30–300 seconds — a server-side rule, checkable only in its implementation, +prisma/pdp-control-plane#4817), heartbeats it on a forked fiber every 20 +seconds, and releases it as a finalizer. Contention keeps ADR-0010's exact behavior: a second deploy of the same `(stack, stage)` fails immediately with the server's message naming the current holder — it never queues. @@ -78,10 +83,11 @@ children of the Branch: delete the Branch (CLI, Console, any platform surface) and the stage's state goes with it; production's state sits on the implicit default Branch. Auth is unchanged too — the same workspace service token the deploy already holds, with no minted per-run connection strings and no -ownership markers, because there is no database to prove ownership of. On the -server the rows are encrypted at rest under the per-project data-encryption -key, and the server enforces bounds on key lengths (stack, stage, fqn), so -malformed scopes fail loudly at the API rather than landing in storage. +ownership markers, because there is no database to prove ownership of. Per +the server implementation (prisma/pdp-control-plane#4816/#4817), the rows are +encrypted at rest under the per-project data-encryption key, and the server +enforces bounds on key lengths (stack, stage, fqn), so malformed scopes fail +loudly at the API rather than landing in storage. One naming wrinkle is deliberate: the store still registers itself with Alchemy's telemetry as `id: 'prisma-postgres'`. That slug identifies the state @@ -119,9 +125,14 @@ deleted by hand — a documented cleanup, not an automated one. within a client-side trust window. The stock client's fatal treatment of 409 is what makes this hold; if the client's retry policy ever changes, this property must be re-verified. -- **The routes are experimental.** The platform may still move them; composer - pins its Alchemy version, so a coordinated change is a normal dependency - bump, not a live break. +- **The routes are experimental, and a route move is a live break.** The + state URL is baked into every published composer version and the platform + serves one live API to all of them, so if the routes move, already + installed versions fail at deploy time until each user upgrades. Alchemy's + contract carries an unauthenticated `/version` probe that can detect + contract drift, but detection only names the break — it does not prevent + it. Accepted while the surface stabilizes; moving the routes is a platform + decision that must weigh this cost. - **No migration.** Legacy stages refuse to deploy until destroyed or deleted (see the deploying guide); their state databases are cleaned up by Branch deletion or by hand, never by this version's code. From df8bc81b8c561da842e5c06ebaa7d83c19ac7b90 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 20:03:19 +0200 Subject: [PATCH 07/10] chore: sync pnpm-lock.yaml with the postgres dependency removal Signed-off-by: willbot Signed-off-by: Will Madden --- pnpm-lock.yaml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 734256363..8623ee012 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -815,9 +815,6 @@ importers: effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103 - postgres: - specifier: ^3.4.9 - version: 3.4.9 devDependencies: '@internal/tsdown-config': specifier: workspace:0.6.0 @@ -1209,9 +1206,6 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.1 - postgres: - specifier: ^3.4.9 - version: 3.4.9 devDependencies: '@internal/assemble': specifier: workspace:0.6.0 @@ -1306,9 +1300,6 @@ importers: pg: specifier: 8.22.0 version: 8.22.0 - postgres: - specifier: ^3.4.9 - version: 3.4.9 tsdown: specifier: ^0.22.7 version: 0.22.12(typescript@6.0.3) @@ -5021,10 +5012,6 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} - postgres@3.4.9: - resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} - engines: {node: '>=12'} - prettier@3.9.5: resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} engines: {node: '>=14'} @@ -9358,8 +9345,6 @@ snapshots: postgres@3.4.7: {} - postgres@3.4.9: {} - prettier@3.9.5: {} prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): From 56d426b012a38968f1fa60ab86e20300ea285e3b Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 7 Aug 2026 20:13:53 +0200 Subject: [PATCH 08/10] =?UTF-8?q?fix(prisma-cloud):=20address=20CodeRabbit?= =?UTF-8?q?=20review=20=E2=80=94=20guard=20covers=20all=20branch=20resourc?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the cutover guard now lists databases and buckets alongside Compute apps (Connections excluded: children of databases), so a legacy stage holding only a database or bucket is refused instead of silently re-provisioned; renamed to failOnEmptyScopeWithLiveResources, message reworded, regression tests for database-only / bucket-only / mixed branches; docs (ADR-0045, deploying guide) match the new wording - releaseDeployLease logs a warning for every non-2xx release response (the lease stays live until TTL), keeping the specific 404 message - FakeStateApi.stop() is deterministic: resolves immediately with no server and destroys open keep-alive sockets before close() Signed-off-by: willbot Signed-off-by: Will Madden --- ...ate-lives-behind-the-platform-state-api.md | 3 +- docs/guides/deploying.md | 3 +- .../src/state/__tests__/empty-scope.test.ts | 66 +++++++++++++--- .../state/__tests__/fake-management-api.ts | 29 ++++++- .../src/state/__tests__/fake-state-api.ts | 24 +++++- .../lowering/src/state/empty-scope.ts | 76 ++++++++++++------- .../0-lowering/lowering/src/state/layer.ts | 15 ++-- .../0-lowering/lowering/src/state/lease.ts | 22 ++++-- 8 files changed, 176 insertions(+), 62 deletions(-) diff --git a/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md index 55478f84f..53648995c 100644 --- a/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md +++ b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md @@ -100,7 +100,8 @@ deployed under the database store starts from empty API state, and deploying over it blind would recreate every resource and die in `already_exists` failures. So the state layer keeps its empty-scope check, re-pointed: after acquiring the lease, if the API holds no resources for `(stack, stage)` but -the Branch already runs live Compute apps, the deploy refuses with +the Branch already holds live resources (Compute apps, databases, or +buckets), the deploy refuses with instructions — destroy the stage with the previous composer version, or delete the stage's Branch (the Project, for production), then redeploy fresh. Legacy `prisma-composer-state` databases are never read and never deleted by diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index ead666182..5b43afcf6 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -274,7 +274,8 @@ store — there is no automated migration. The cutover is the same for both generations: destroy, upgrade, redeploy. Deploying over a live legacy environment is refused up front. The deploy -finds no API-hosted state but sees apps already running on the Branch, and +finds no API-hosted state but sees resources (apps, databases, or buckets) +already on the Branch, and stops with an error saying the stage predates the platform state API — instead of blindly recreating every resource and failing halfway. Cut over per app: diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts index f9311d46b..33ba9a20a 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/empty-scope.test.ts @@ -2,17 +2,17 @@ import { describe, expect, test } from 'bun:test'; import * as Effect from 'effect/Effect'; import { ManagementClient } from '../../client.ts'; import { PrismaApiError } from '../../http.ts'; -import { failOnEmptyScopeWithLiveApps } from '../empty-scope.ts'; +import { failOnEmptyScopeWithLiveResources } from '../empty-scope.ts'; import { fakeClient, newFakeState, PROJECT_ID } from './fake-management-api.ts'; -describe('failOnEmptyScopeWithLiveApps', () => { +describe('failOnEmptyScopeWithLiveResources', () => { const branchId = 'br-default'; const stack = 'demo-stack'; const stage = 'br_test123'; const check = (state = newFakeState()) => Effect.runPromise( - failOnEmptyScopeWithLiveApps(PROJECT_ID, branchId, stack, stage).pipe( + failOnEmptyScopeWithLiveResources(PROJECT_ID, branchId, stack, stage).pipe( Effect.provideService(ManagementClient, fakeClient(state)), ), ); @@ -35,11 +35,52 @@ describe('failOnEmptyScopeWithLiveApps', () => { const message = (error as PrismaApiError).message; expect(message).toContain(`no deploy state for stage "${stage}"`); expect(message).toContain(branchId); - expect(message).toContain('"storefront.web"'); - expect(message).toContain('"storefront.worker"'); + expect(message).toContain('app "storefront.web"'); + expect(message).toContain('app "storefront.worker"'); expect(message).toContain('already_exists'); }); + test('a database-only legacy stage fails too — a legacy prisma-composer-state database alone trips the guard', async () => { + const state = newFakeState({ + databases: [{ id: 'db-1', name: 'prisma-composer-state', projectId: PROJECT_ID, branchId }], + }); + + const error: unknown = await check(state).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(PrismaApiError); + const message = (error as PrismaApiError).message; + expect(message).toContain('1 resource(s)'); + expect(message).toContain('database "prisma-composer-state"'); + expect(message).toContain('predates the platform state API'); + }); + + test('a bucket-only branch fails too', async () => { + const state = newFakeState({ + buckets: [{ id: 'bkt-1', name: 'files', projectId: PROJECT_ID, branchId }], + }); + + const error: unknown = await check(state).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(PrismaApiError); + expect((error as PrismaApiError).message).toContain('bucket "files"'); + }); + + test('mixed kinds are all named, each with its kind', async () => { + const state = newFakeState({ + apps: [{ id: 'app-1', name: 'storefront.web', projectId: PROJECT_ID, branchId }], + databases: [{ id: 'db-1', name: 'database', projectId: PROJECT_ID, branchId }], + buckets: [{ id: 'bkt-1', name: 'files', projectId: PROJECT_ID, branchId }], + }); + + const error: unknown = await check(state).catch((e: unknown) => e); + + const message = (error as PrismaApiError).message; + expect(message).toContain('3 resource(s)'); + expect(message).toContain('app "storefront.web"'); + expect(message).toContain('database "database"'); + expect(message).toContain('bucket "files"'); + }); + test('the message says the stage predates the platform state API and how to cut over', async () => { const state = newFakeState({ apps: [{ id: 'app-1', name: 'storefront.web', projectId: PROJECT_ID, branchId }], @@ -69,10 +110,10 @@ describe('failOnEmptyScopeWithLiveApps', () => { expect(error).toBeInstanceOf(PrismaApiError); const message = (error as PrismaApiError).message; - expect(message).toContain('3 app(s)'); - expect(message).toContain('"storefront.web"'); - expect(message).toContain('"storefront.worker"'); - expect(message).toContain('"storefront.jobs"'); + expect(message).toContain('3 resource(s)'); + expect(message).toContain('app "storefront.web"'); + expect(message).toContain('app "storefront.worker"'); + expect(message).toContain('app "storefront.jobs"'); // Each app appears exactly once — pagination never double-counts. expect(message.match(/storefront\.web/g)).toHaveLength(1); }); @@ -101,17 +142,20 @@ describe('failOnEmptyScopeWithLiveApps', () => { expect((error as PrismaApiError).message).toContain('pagination appears broken'); }); - test("apps on a DIFFERENT branch don't count — another stage's apps never block this one", async () => { + test("resources on a DIFFERENT branch don't count — another stage's resources never block this one", async () => { const state = newFakeState({ apps: [{ id: 'app-1', name: 'storefront.web', projectId: PROJECT_ID, branchId: 'br-other' }], + databases: [{ id: 'db-1', name: 'database', projectId: PROJECT_ID, branchId: 'br-other' }], + buckets: [{ id: 'bkt-1', name: 'files', projectId: PROJECT_ID, branchId: 'br-other' }], }); await expect(check(state)).resolves.toBeUndefined(); }); - test("another project's apps don't count", async () => { + test("another project's resources don't count", async () => { const state = newFakeState({ apps: [{ id: 'app-1', name: 'storefront.web', projectId: 'proj-other', branchId }], + databases: [{ id: 'db-1', name: 'database', projectId: 'proj-other', branchId }], }); await expect(check(state)).resolves.toBeUndefined(); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts index 15ec32286..c1ce04049 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-management-api.ts @@ -1,7 +1,7 @@ import { blindCast } from '@internal/foundation/casts'; import type { ManagementApiClient } from '../../client.ts'; -export interface FakeApp { +export interface FakeBranchResource { id: string; name: string; projectId: string; @@ -9,7 +9,9 @@ export interface FakeApp { } export interface FakeState { - apps: FakeApp[]; + apps: FakeBranchResource[]; + databases: FakeBranchResource[]; + buckets: FakeBranchResource[]; /** Page size for GET /v1/apps — unset serves everything in one page. */ appsPageSize?: number; /** When set, GET /v1/apps reports hasMore with a nextCursor equal to the request's cursor — a broken, non-advancing pagination. */ @@ -18,6 +20,8 @@ export interface FakeState { export const newFakeState = (overrides: Partial = {}): FakeState => ({ apps: [], + databases: [], + buckets: [], ...overrides, }); @@ -33,12 +37,27 @@ type FakeInit = { /** * A stubbed `ManagementApiClient` — just enough of the Management API to - * exercise the empty-scope guard's app listing without touching the cloud. + * exercise the empty-scope guard's branch listings (apps, databases, + * buckets) without touching the cloud. Pagination quirks (page size, stuck + * cursor) are modelled on /v1/apps only; the guard drives all three listings + * through the same pagination helper. */ export const fakeClient = (state: FakeState): ManagementApiClient => { + const singlePage = (rows: FakeBranchResource[], query: Record) => { + const filtered = rows.filter( + (row) => + (query['projectId'] === undefined || row.projectId === query['projectId']) && + (query['branchId'] === undefined || row.branchId === query['branchId']), + ); + return okResponse({ + data: filtered, + pagination: { nextCursor: null, hasMore: false }, + }); + }; + const GET = (path: string, init: FakeInit = {}) => { + const query = init.params?.query ?? {}; if (path === '/v1/apps') { - const query = init.params?.query ?? {}; const filtered = state.apps.filter( (app) => (query['projectId'] === undefined || app.projectId === query['projectId']) && @@ -61,6 +80,8 @@ export const fakeClient = (state: FakeState): ManagementApiClient => { }), ); } + if (path === '/v1/databases') return Promise.resolve(singlePage(state.databases, query)); + if (path === '/v1/buckets') return Promise.resolve(singlePage(state.buckets, query)); throw new Error(`fakeClient: unexpected GET ${path}`); }; diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts index bf3bde8dc..7824597d1 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/__tests__/fake-state-api.ts @@ -1,11 +1,13 @@ import * as http from 'node:http'; +import type * as net from 'node:net'; /** * An in-process fake of the platform Alchemy state API: the alchemy * `HttpStateApi` wire contract (see node_modules/alchemy/src/state/ * HttpStateApi.ts) mounted under `/v1/projects/{p}/branches/{b}/alchemy-state`, - * plus the deploy-lease endpoints and the two Management API listings the - * state layer touches (`/v1/apps`, `/v1/projects/{p}/branches`). + * plus the deploy-lease endpoints and the Management API listings the state + * layer touches (`/v1/apps`, `/v1/databases`, `/v1/buckets`, + * `/v1/projects/{p}/branches`). * * Wire fidelity the tests depend on: absent values answer 200 with a JSON * `null` body (not 204); PUT echoes its payload; DELETE answers 204; the fqn @@ -78,6 +80,7 @@ export class FakeStateApi { private readonly leases = new Map(); private leaseCounter = 0; private server: http.Server | undefined; + private readonly sockets = new Set(); private originValue = ''; get origin(): string { @@ -88,6 +91,10 @@ export class FakeStateApi { this.server = http.createServer((req, res) => { void this.handle(req, res); }); + this.server.on('connection', (socket) => { + this.sockets.add(socket); + socket.on('close', () => this.sockets.delete(socket)); + }); await new Promise((resolve) => this.server?.listen(0, '127.0.0.1', resolve)); const address = this.server.address(); if (address === null || typeof address === 'string') { @@ -96,9 +103,15 @@ export class FakeStateApi { this.originValue = `http://127.0.0.1:${String(address.port)}`; } + /** Deterministic shutdown: no server resolves immediately; open keep-alive sockets are destroyed so close() cannot hang. */ async stop(): Promise { + const server = this.server; + if (server === undefined) return; + this.server = undefined; + for (const socket of this.sockets) socket.destroy(); + this.sockets.clear(); await new Promise((resolve, reject) => { - this.server?.close((err) => (err ? reject(err) : resolve())); + server.close((err) => (err ? reject(err) : resolve())); }); } @@ -157,6 +170,11 @@ export class FakeStateApi { return json(res, 200, { data, pagination: { nextCursor: null, hasMore: false } }); } + // The guard also lists databases and buckets; these suites seed neither. + if ((segments[1] === 'databases' || segments[1] === 'buckets') && method === 'GET') { + return json(res, 200, { data: [], pagination: { nextCursor: null, hasMore: false } }); + } + if ( segments[1] === 'projects' && segments[3] === 'branches' && diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts index bcb8dd49d..18808c35e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/empty-scope.ts @@ -32,41 +32,59 @@ export const scopeOccupied = ( ), ).pipe(Effect.map((fqns) => fqns.length > 0)); -interface AppSummary { +interface NamedResource { readonly id: string; readonly name: string; } -// Bounded (collectPages): this check runs under the deploy lease, so broken +// Bounded (collectPages): these checks run under the deploy lease, so broken // pagination must fail loudly, never hang or pass on a partial listing. -const listAppsOnBranch = ( +// Connections are deliberately not listed — they are children of databases, +// so the database listing already covers every stage that has one. +const listBranchResources = ( client: ManagementApiClient, projectId: string, branchId: string, -): Effect.Effect => - collectPages(`apps on branch ${branchId}`, (cursor) => - call(() => - client.GET('/v1/apps', { - params: { - query: cursor === undefined ? { projectId, branchId } : { projectId, branchId, cursor }, - }, - }), - ), - ); +): Effect.Effect< + readonly { kind: 'app' | 'database' | 'bucket'; name: string }[], + PrismaApiError +> => + Effect.gen(function* () { + const query = (cursor: string | undefined) => + cursor === undefined ? { projectId, branchId } : { projectId, branchId, cursor }; + const apps: readonly NamedResource[] = yield* collectPages( + `apps on branch ${branchId}`, + (cursor) => call(() => client.GET('/v1/apps', { params: { query: query(cursor) } })), + ); + const databases: readonly NamedResource[] = yield* collectPages( + `databases on branch ${branchId}`, + (cursor) => call(() => client.GET('/v1/databases', { params: { query: query(cursor) } })), + ); + const buckets: readonly NamedResource[] = yield* collectPages( + `buckets on branch ${branchId}`, + (cursor) => call(() => client.GET('/v1/buckets', { params: { query: query(cursor) } })), + ); + return [ + ...apps.map((r) => ({ kind: 'app' as const, name: r.name })), + ...databases.map((r) => ({ kind: 'database' as const, name: r.name })), + ...buckets.map((r) => ({ kind: 'bucket' as const, name: r.name })), + ]; + }); /** - * The empty-scope-with-live-apps case: the platform state API holds no - * resources for (stack, stage) while the platform already runs Compute apps - * on the target Branch — this stage predates the platform state API (its - * state lives in a legacy `prisma-composer-state` database, which is never - * read; there is no automatic migration), or the deploy targets a project - * that already runs apps. Deploying would recreate every resource and die in - * per-resource `already_exists` failures — so fail once, up front. A - * genuinely fresh deploy sees an empty Branch and passes. Local dev never - * reaches this — the dev stack pins `state: localState()` - * (generate-dev-stack.ts). + * The empty-scope-with-live-resources case: the platform state API holds no + * resources for (stack, stage) while the platform already has Compute apps, + * databases, or buckets on the target Branch — this stage predates the + * platform state API (its state lives in a legacy `prisma-composer-state` + * database, which is never read; there is no automatic migration), or the + * deploy targets a project that already runs something. Deploying would + * recreate every resource and die in per-resource `already_exists` failures — + * so fail once, up front. A genuinely fresh deploy sees an empty Branch and + * passes. Connections are not counted: they are children of databases, which + * are. Local dev never reaches this — the dev stack pins + * `state: localState()` (generate-dev-stack.ts). */ -export const failOnEmptyScopeWithLiveApps = ( +export const failOnEmptyScopeWithLiveResources = ( projectId: string, branchId: string, stack: string, @@ -74,20 +92,20 @@ export const failOnEmptyScopeWithLiveApps = ( ): Effect.Effect => Effect.gen(function* () { const client = yield* ManagementClient; - const apps = yield* listAppsOnBranch(client, projectId, branchId); - if (apps.length === 0) return; - const names = apps.map((app) => `"${app.name}"`).join(', '); + const resources = yield* listBranchResources(client, projectId, branchId); + if (resources.length === 0) return; + const names = resources.map((r) => `${r.kind} "${r.name}"`).join(', '); return yield* Effect.fail( new PrismaApiError({ status: 0, message: `the platform state API holds no deploy state for stage "${stage}" (stack "${stack}"), but the ` + - `platform already runs ${String(apps.length)} app(s) on the target branch ${branchId}: ${names}. ` + + `platform already has ${String(resources.length)} resource(s) on the target branch ${branchId}: ${names}. ` + 'This stage predates the platform state API. With no state, a deploy would recreate every ' + 'resource and fail with already_exists, and a destroy would remove nothing. Destroy the stage ' + 'with the previous version of composer, or delete the stage (its branch — or the project, for ' + 'production) in the Prisma Console or via the Management API — then redeploy fresh. If those ' + - "apps are another deployment's, remove them or deploy into a different project. Then retry.", + "resources are another deployment's, remove them or deploy into a different project. Then retry.", }), ); }); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts index c5a97e070..ef4188209 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/layer.ts @@ -8,7 +8,7 @@ import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; import * as client from '../client.ts'; import { resolveDefaultBranchId } from '../container.ts'; import * as credentials from '../credentials.ts'; -import { failOnEmptyScopeWithLiveApps, scopeOccupied } from './empty-scope.ts'; +import { failOnEmptyScopeWithLiveResources, scopeOccupied } from './empty-scope.ts'; import { hostedStateBootstrapError } from './errors.ts'; import { acquireDeployLease, @@ -88,14 +88,19 @@ export const stateLayerAgainst = ( yield* Effect.forkScoped(heartbeatDeployLease(mgmt, scope, lease)); // Before the store exists — so the check precedes Alchemy's first state - // read. An empty scope with live apps on the Branch means the stage - // predates the platform state API (or a foreign deployment): refuse - // before Alchemy mutates any resource. + // read. An empty scope with live resources (apps, databases, buckets) + // on the Branch means the stage predates the platform state API (or a + // foreign deployment): refuse before Alchemy mutates any resource. const occupied = yield* scopeOccupied(mgmt, scope, lease).pipe( Effect.mapError(bootstrapError('probing the deploy state scope')), ); if (!occupied) { - yield* failOnEmptyScopeWithLiveApps(projectId, stateBranchId, stack.name, stack.stage).pipe( + yield* failOnEmptyScopeWithLiveResources( + projectId, + stateBranchId, + stack.name, + stack.stage, + ).pipe( Effect.provideService(client.ManagementClient, mgmt), Effect.mapError(bootstrapError('checking the empty deploy state scope')), ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts index 5838a462f..218b7427e 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/state/lease.ts @@ -151,14 +151,20 @@ export const releaseDeployLease = ( }, }), ).pipe( - Effect.flatMap((r) => - r.response.status === 404 - ? Effect.logWarning( - `releasing the deploy lease for stage "${scope.stage}" returned 404 — ` + - 'it had already expired or been replaced.', - ) - : Effect.void, - ), + Effect.flatMap((r) => { + const status = r.response.status; + if (status === 404) { + return Effect.logWarning( + `releasing the deploy lease for stage "${scope.stage}" returned 404 — ` + + 'it had already expired or been replaced.', + ); + } + if (status >= 200 && status < 300) return Effect.void; + return Effect.logWarning( + `releasing the deploy lease for stage "${scope.stage}" returned HTTP ${String(status)} — ` + + 'the lease stays live until its TTL expires.', + ); + }), Effect.catch((cause) => Effect.logWarning( `releasing the deploy lease for stage "${scope.stage}" failed: ${String(cause)}`, From 1043d817d28a493ca0377e8f9eabf7a4be169c07 Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 9 Aug 2026 13:33:00 +0200 Subject: [PATCH 09/10] docs: unwrap hard-wrapped markdown prose in the paragraphs this PR touched Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/03-domain-model/glossary.md | 6 +- docs/design/03-domain-model/layering.md | 27 +-- docs/design/10-domains/deploy-cli.md | 23 +-- ...10-deploys-hold-a-session-advisory-lock.md | 7 +- ...012-the-state-store-speaks-sql-directly.md | 6 +- ...-deploy-state-lives-in-the-stage-branch.md | 7 +- ...ate-lives-behind-the-platform-state-api.md | 186 ++++-------------- docs/guides/deploying.md | 56 ++---- 8 files changed, 55 insertions(+), 263 deletions(-) diff --git a/docs/design/03-domain-model/glossary.md b/docs/design/03-domain-model/glossary.md index 14a591adb..b2d82f9fd 100644 --- a/docs/design/03-domain-model/glossary.md +++ b/docs/design/03-domain-model/glossary.md @@ -397,11 +397,7 @@ is in `layering.md`; this is the term-by-term catalogue. *above* providers, not inside them. - **Stage** — an isolated instance of a Stack (`dev`, `staging`, `prod`, `pr-42`) with its own state and physical names. `→` **Environment**. -- **State store** — persists each Resource's state per stack+stage so the engine - can diff the next deploy. `prismaCloud()` defaults every deploy to - platform-hosted state behind the Management API, scoped to the stage's - Branch (`@internal/lowering/state`, ADR-0045); an explicit state layer - always overrides it. Control-plane infra, never a topology node. +- **State store** — persists each Resource's state per stack+stage so the engine can diff the next deploy. `prismaCloud()` defaults every deploy to platform-hosted state behind the Management API, scoped to the stage's Branch (`@internal/lowering/state`, ADR-0045); an explicit state layer always overrides it. Control-plane infra, never a topology node. ### Alchemy — engine verbs (provider lifecycle) diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index f9c0735a0..ee5856d85 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -120,33 +120,12 @@ reproduce-in-the-emulator goal (see `../00-purpose/goals.md`). ## Provisioning & state -Provisioning runs through **Alchemy's engine**, invoked from the client or a -privileged CD environment (see claim 3). The engine keeps a **state store** — -the source of truth for what's provisioned. State sits on a spectrum from -local, to platform-hosted (where we are), to eventually platform-run: +Provisioning runs through **Alchemy's engine**, invoked from the client or a privileged CD environment (see claim 3). The engine keeps a **state store** — the source of truth for what's provisioned. State sits on a spectrum from local, to platform-hosted (where we are), to eventually platform-run: - **Local** — Alchemy's local or Cloudflare-backed state. Fine for a solo developer; nothing else needs to see it. -- **Platform-hosted** — the Management API implements Alchemy's own HTTP - `StateApi` wire contract per Branch of the app's own Project - (`…/branches/{branchId}/alchemy-state`, ADR-0045), and the framework's - state layer (`@internal/lowering/state`) is Alchemy's stock HTTP client - pointed at it — Pulumi/Terraform-Cloud-style hosted state, native to the - Workspace → Project → Branch hierarchy, with no BYO-state bootstrap and no - visible state database. A deployer needs nothing beyond the service token - it already has, and the state's lifetime is the environment's — deleting - the Branch or Project deletes it. Concurrency is a server-side - per-`(stack, stage)` deploy lease held around the run, so two deployers - can never race the same stack. `prismaCloud()` supplies this as the - default deploy state for every service and Module; an explicit state layer - always overrides it. Like hosted-state backends generally, it also holds - state for the user's BYO resources in other clouds, and it lets the - platform answer "what's provisioned in this project" natively (the - platform side of the inspectable-topology goal). -- **Server-side runs** — the platform executes the apply loop itself - (git-push-style deploys). With state already platform-hosted, moving the - engine server-side is incremental — the same evolution Pulumi/Terraform - Cloud followed. +- **Platform-hosted** — the Management API implements Alchemy's own HTTP `StateApi` wire contract per Branch of the app's own Project (`…/branches/{branchId}/alchemy-state`, ADR-0045), and the framework's state layer (`@internal/lowering/state`) is Alchemy's stock HTTP client pointed at it — Pulumi/Terraform-Cloud-style hosted state, native to the Workspace → Project → Branch hierarchy, with no BYO-state bootstrap and no visible state database. A deployer needs nothing beyond the service token it already has, and the state's lifetime is the environment's — deleting the Branch or Project deletes it. Concurrency is a server-side per-`(stack, stage)` deploy lease held around the run, so two deployers can never race the same stack. `prismaCloud()` supplies this as the default deploy state for every service and Module; an explicit state layer always overrides it. Like hosted-state backends generally, it also holds state for the user's BYO resources in other clouds, and it lets the platform answer "what's provisioned in this project" natively (the platform side of the inspectable-topology goal). +- **Server-side runs** — the platform executes the apply loop itself (git-push-style deploys). With state already platform-hosted, moving the engine server-side is incremental — the same evolution Pulumi/Terraform Cloud followed. ## Open questions diff --git a/docs/design/10-domains/deploy-cli.md b/docs/design/10-domains/deploy-cli.md index 36e5b34bf..bdbfb55bf 100644 --- a/docs/design/10-domains/deploy-cli.md +++ b/docs/design/10-domains/deploy-cli.md @@ -130,26 +130,9 @@ targets **production**; `--stage ` targets a **named stage**. extension; core hands that extension's own resolved container to `state.create()`, so the state layer is built from it rather than from the environment. -- **Destroy is explicit.** `prisma-composer destroy` requires `--stage ` or - `--production`; a bare `destroy` is an error, so an omitted or mistyped - stage can never silently tear down production. `destroy` resolves - find-only (no container is ever created); after `alchemy destroy` succeeds - and after every extension's `teardown` has run, the CLI removes each - resolved container. That two-loop order — every teardown, then every - removal — is what guarantees every extension's teardown runs against a - still-live container. - -**Prisma Cloud's own containers** are its app's **Project** and, for a named -stage, that stage's **Branch** — found by name, created if absent on deploy, -never created on destroy; each stage's deploy state lives behind the platform -state API, scoped to its Branch (production's to the Project's implicit -default Branch). See -[ADR-0023](../90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) -(App = one Project, Stage = Branch), -[ADR-0024](../90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) -(stage resolution mechanics), and -[ADR-0045](../90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) -(deploy state behind the platform state API, per Branch). +- **Destroy is explicit.** `prisma-composer destroy` requires `--stage ` or `--production`; a bare `destroy` is an error, so an omitted or mistyped stage can never silently tear down production. `destroy` resolves find-only (no container is ever created); after `alchemy destroy` succeeds and after every extension's `teardown` has run, the CLI removes each resolved container. That two-loop order — every teardown, then every removal — is what guarantees every extension's teardown runs against a still-live container. + +**Prisma Cloud's own containers** are its app's **Project** and, for a named stage, that stage's **Branch** — found by name, created if absent on deploy, never created on destroy; each stage's deploy state lives behind the platform state API, scoped to its Branch (production's to the Project's implicit default Branch). See [ADR-0023](../90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) (App = one Project, Stage = Branch), [ADR-0024](../90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) (stage resolution mechanics), and [ADR-0045](../90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md) (deploy state behind the platform state API, per Branch). ## Build ownership diff --git a/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md b/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md index 8b790aa36..77a373f45 100644 --- a/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md +++ b/docs/design/90-decisions/ADR-0010-deploys-hold-a-session-advisory-lock.md @@ -1,11 +1,6 @@ # ADR-0010: Deploys hold a session advisory lock per stack and stage -> Superseded by -> [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md): -> the per-`(stack, stage)` deploy lease is now held server-side against the -> platform state API (TTL + heartbeat), not as a Postgres session advisory -> lock. The fail-fast contention behavior — refuse immediately, name the -> holder, never queue — is preserved. +> Superseded by [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md): the per-`(stack, stage)` deploy lease is now held server-side against the platform state API (TTL + heartbeat), not as a Postgres session advisory lock. The fail-fast contention behavior — refuse immediately, name the holder, never queue — is preserved. ## Decision diff --git a/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md b/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md index 53f80a0be..af4551e46 100644 --- a/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md +++ b/docs/design/90-decisions/ADR-0012-the-state-store-speaks-sql-directly.md @@ -1,10 +1,6 @@ # ADR-0012: The state store speaks SQL directly; Prisma Next adoption is deferred -> Closed as obsolete by -> [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md), -> via this record's own pick-up trigger: the platform-side state API landed, -> the SQL store is gone, and composer speaks the API through Alchemy's stock -> HTTP client — there is no store data layer left to adopt Prisma Next for. +> Closed as obsolete by [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md), via this record's own pick-up trigger: the platform-side state API landed, the SQL store is gone, and composer speaks the API through Alchemy's stock HTTP client — there is no store data layer left to adopt Prisma Next for. ## Decision diff --git a/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md b/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md index 405fea57c..08ba4bab5 100644 --- a/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md +++ b/docs/design/90-decisions/ADR-0034-deploy-state-lives-in-the-stage-branch.md @@ -1,11 +1,6 @@ # ADR-0034: Deploy state lives in a framework-owned database in the stage's Branch -> Superseded in part by -> [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md): -> the container and lifetime reasoning stands — state is still a child of the -> stage's Branch, deleted with it — but the storage mechanism is replaced. -> State lives behind the platform state API; the visible per-stage -> `prisma-composer-state` database is gone. +> Superseded in part by [ADR-0045](ADR-0045-deploy-state-lives-behind-the-platform-state-api.md): the container and lifetime reasoning stands — state is still a child of the stage's Branch, deleted with it — but the storage mechanism is replaced. State lives behind the platform state API; the visible per-stage `prisma-composer-state` database is gone. ## Decision diff --git a/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md index 53648995c..c2a8924e6 100644 --- a/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md +++ b/docs/design/90-decisions/ADR-0045-deploy-state-lives-behind-the-platform-state-api.md @@ -2,14 +2,7 @@ ## Decision -Each stage's deploy state — the provisioning engine's record of what exists in -the cloud — lives behind the Management API. The platform implements Alchemy's -stock `HttpStateApi` wire contract verbatim under -`/v1/projects/{projectId}/branches/{branchId}/alchemy-state`, and composer's -state layer is Alchemy's own stock HTTP client (`makeHttpStateStore`) pointed -at it — no store code of our own. Around every run the deploy holds a -**server-side lease** per `(stack, stage)`; every state operation carries the -lease id and the server rejects any operation without a live lease. +Each stage's deploy state — the provisioning engine's record of what exists in the cloud — lives behind the Management API. The platform implements Alchemy's stock `HttpStateApi` wire contract verbatim under `/v1/projects/{projectId}/branches/{branchId}/alchemy-state`, and composer's state layer is Alchemy's own stock HTTP client (`makeHttpStateStore`) pointed at it — no store code of our own. Around every run the deploy holds a **server-side lease** per `(stack, stage)`; every state operation carries the lease id and the server rejects any operation without a live lease. ``` deploy run @@ -22,159 +15,48 @@ deploy run └─ DELETE …/alchemy-state/lease release (scoped finalizer) ``` -This supersedes -[ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — the Postgres -session advisory lock becomes this lease; the fail-fast contention behavior is -preserved — and the storage half of -[ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md): state is still -a child of the stage's Branch with exactly the environment's lifetime, but the -visible per-stage `prisma-composer-state` database disappears. It closes -[ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) as obsolete via -that record's own pick-up trigger ("the platform-side state API lands — this -store shrinks to a client or disappears"). -[ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) is unchanged: -the Prisma Cloud target still supplies this layer as the deploy's state store. +This supersedes [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — the Postgres session advisory lock becomes this lease; the fail-fast contention behavior is preserved — and the storage half of [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md): state is still a child of the stage's Branch with exactly the environment's lifetime, but the visible per-stage `prisma-composer-state` database disappears. It closes [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) as obsolete via that record's own pick-up trigger ("the platform-side state API lands — this store shrinks to a client or disappears"). [ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) is unchanged: the Prisma Cloud target still supplies this layer as the deploy's state store. ## Reasoning -The end state was recorded twice before it existed. ADR-0009 named a -platform-side state API as where hosted state ultimately belongs; ADR-0034 -called its own database store the proof of the *right shape* for that API — -state scoped as a child of the Branch, cascading on delete. The platform now -implements that API, so composer stops carrying the interim machinery: the -per-stage database, its bootstrap/ownership-marker/connection-minting code, -the SQL store, and the advisory lock with its liveness checker. - -Because the server speaks Alchemy's stock wire contract verbatim, the client -side is not ours to write. Composer builds Alchemy's own `makeHttpStateStore` -with the scope's URL, the workspace service token, and one request transform -that adds the lease header. There is no SQL, no driver, no schema, and no -store of our own whose storage correctness we must prove — the contract is -Alchemy's, proven by Alchemy. Composer does keep an in-process fake of the -wire contract to test its own wiring (the lease lifecycle, the guard, the -client pointed at our URL shape); a fake can drift from the contract it -mirrors and must track it. What remains in composer beyond that is scope -resolution (a URL needs a concrete `branchId`; production resolves the -Project's default Branch), the lease client, and operator-facing error -wrapping. - -The lease replaces the advisory lock because the lock's substrate is gone. -ADR-0010 chose a Postgres session lock precisely because it was a lease the -store's own database provided for free — bound to a connection, released on -crash. With no database there is no session, so the lease moves to where the -state now lives: the server. A deploy acquires it before the first state -operation (60-second TTL by default; the server clamps requested TTLs to -30–300 seconds — a server-side rule, checkable only in its implementation, -prisma/pdp-control-plane#4817), heartbeats it on a forked fiber every 20 -seconds, and releases it as a finalizer. Contention keeps ADR-0010's exact behavior: a -second deploy of the same `(stack, stage)` fails immediately with the server's -message naming the current holder — it never queues. - -Enforcement also moves server-side, which deletes a whole client subsystem. -Under ADR-0010 the client had to *notice* a lost lock, and its liveness -checker existed to work around driver crash behavior. Now every state -operation is checked by the server: without a live lease it fails with 409 — a -status the stock client treats as fatal (it retries transient failures, never -409) — so a run that loses its lease stops within at most one further request. -No client-side liveness check exists at all. - -What does not change is the addressing ADR-0034 fought for. State rows are -children of the Branch: delete the Branch (CLI, Console, any platform surface) -and the stage's state goes with it; production's state sits on the implicit -default Branch. Auth is unchanged too — the same workspace service token the -deploy already holds, with no minted per-run connection strings and no -ownership markers, because there is no database to prove ownership of. Per -the server implementation (prisma/pdp-control-plane#4816/#4817), the rows are -encrypted at rest under the per-project data-encryption key, and the server -enforces bounds on key lengths (stack, stage, fqn), so malformed scopes fail -loudly at the API rather than landing in storage. - -One naming wrinkle is deliberate: the store still registers itself with -Alchemy's telemetry as `id: 'prisma-postgres'`. That slug identifies the state -*service* in metrics and spans (`alchemy.state_store.id`), and changing it -would split every dashboard series keyed on it. The slug outlives the database -it once described; it now just means "Prisma-hosted state". - -The cutover carries no migration, on ADR-0034's own precedent. A stage -deployed under the database store starts from empty API state, and deploying -over it blind would recreate every resource and die in `already_exists` -failures. So the state layer keeps its empty-scope check, re-pointed: after -acquiring the lease, if the API holds no resources for `(stack, stage)` but -the Branch already holds live resources (Compute apps, databases, or -buckets), the deploy refuses with -instructions — destroy the stage with the previous composer version, or -delete the stage's Branch (the Project, for production), then redeploy fresh. -Legacy `prisma-composer-state` databases are never read and never deleted by -this version: deleting a stage's Branch removes that stage's database -platform-side, while production's lingers (one quota slot, no money) until -deleted by hand — a documented cleanup, not an automated one. +The end state was recorded twice before it existed. ADR-0009 named a platform-side state API as where hosted state ultimately belongs; ADR-0034 called its own database store the proof of the *right shape* for that API — state scoped as a child of the Branch, cascading on delete. The platform now implements that API, so composer stops carrying the interim machinery: the per-stage database, its bootstrap/ownership-marker/connection-minting code, the SQL store, and the advisory lock with its liveness checker. + +Because the server speaks Alchemy's stock wire contract verbatim, the client side is not ours to write. Composer builds Alchemy's own `makeHttpStateStore` with the scope's URL, the workspace service token, and one request transform that adds the lease header. There is no SQL, no driver, no schema, and no store of our own whose storage correctness we must prove — the contract is Alchemy's, proven by Alchemy. Composer does keep an in-process fake of the wire contract to test its own wiring (the lease lifecycle, the guard, the client pointed at our URL shape); a fake can drift from the contract it mirrors and must track it. What remains in composer beyond that is scope resolution (a URL needs a concrete `branchId`; production resolves the Project's default Branch), the lease client, and operator-facing error wrapping. + +The lease replaces the advisory lock because the lock's substrate is gone. ADR-0010 chose a Postgres session lock precisely because it was a lease the store's own database provided for free — bound to a connection, released on crash. With no database there is no session, so the lease moves to where the state now lives: the server. A deploy acquires it before the first state operation (60-second TTL by default; the server clamps requested TTLs to 30–300 seconds — a server-side rule, checkable only in its implementation, prisma/pdp-control-plane#4817), heartbeats it on a forked fiber every 20 seconds, and releases it as a finalizer. Contention keeps ADR-0010's exact behavior: a second deploy of the same `(stack, stage)` fails immediately with the server's message naming the current holder — it never queues. + +Enforcement also moves server-side, which deletes a whole client subsystem. Under ADR-0010 the client had to *notice* a lost lock, and its liveness checker existed to work around driver crash behavior. Now every state operation is checked by the server: without a live lease it fails with 409 — a status the stock client treats as fatal (it retries transient failures, never 409) — so a run that loses its lease stops within at most one further request. No client-side liveness check exists at all. + +What does not change is the addressing ADR-0034 fought for. State rows are children of the Branch: delete the Branch (CLI, Console, any platform surface) and the stage's state goes with it; production's state sits on the implicit default Branch. Auth is unchanged too — the same workspace service token the deploy already holds, with no minted per-run connection strings and no ownership markers, because there is no database to prove ownership of. Per the server implementation (prisma/pdp-control-plane#4816/#4817), the rows are encrypted at rest under the per-project data-encryption key, and the server enforces bounds on key lengths (stack, stage, fqn), so malformed scopes fail loudly at the API rather than landing in storage. + +One naming wrinkle is deliberate: the store still registers itself with Alchemy's telemetry as `id: 'prisma-postgres'`. That slug identifies the state *service* in metrics and spans (`alchemy.state_store.id`), and changing it would split every dashboard series keyed on it. The slug outlives the database it once described; it now just means "Prisma-hosted state". + +The cutover carries no migration, on ADR-0034's own precedent. A stage deployed under the database store starts from empty API state, and deploying over it blind would recreate every resource and die in `already_exists` failures. So the state layer keeps its empty-scope check, re-pointed: after acquiring the lease, if the API holds no resources for `(stack, stage)` but the Branch already holds live resources (Compute apps, databases, or buckets), the deploy refuses with instructions — destroy the stage with the previous composer version, or delete the stage's Branch (the Project, for production), then redeploy fresh. Legacy `prisma-composer-state` databases are never read and never deleted by this version: deleting a stage's Branch removes that stage's database platform-side, while production's lingers (one quota slot, no money) until deleted by hand — a documented cleanup, not an automated one. ## Consequences -- **No visible state database.** The Console shows only the user's own - databases; the quota slot each stage's store consumed (ADR-0034's standing - consequence) is returned, and the delete-the-state-database-by-hand footgun - disappears with it. -- **Crash recovery trades instant for bounded.** The advisory lock freed the - moment a crashed deploy's connection dropped; a crashed deploy's lease now - blocks the stage until its TTL expires — up to 60 seconds. Accepted: rare - case, small bound, and the retrying operator sees who holds the lease. -- **Contention behavior is preserved.** Fail fast, never queue, error names - the holder. A `--wait` affordance can still layer over the same lease later - without changing its semantics. -- **Lease loss is detected server-side within one extra request**, instead of - within a client-side trust window. The stock client's fatal treatment of - 409 is what makes this hold; if the client's retry policy ever changes, - this property must be re-verified. -- **The routes are experimental, and a route move is a live break.** The - state URL is baked into every published composer version and the platform - serves one live API to all of them, so if the routes move, already - installed versions fail at deploy time until each user upgrades. Alchemy's - contract carries an unauthenticated `/version` probe that can detect - contract drift, but detection only names the break — it does not prevent - it. Accepted while the surface stabilizes; moving the routes is a platform - decision that must weigh this cost. -- **No migration.** Legacy stages refuse to deploy until destroyed or - deleted (see the deploying guide); their state databases are cleaned up by - Branch deletion or by hand, never by this version's code. -- **Platform-side teardown still covers platform resources only.** State can - track resources outside Prisma Cloud; deleting the Branch deletes the only - record of them. Same documented limitation as ADR-0034, same shape. -- **Telemetry continuity.** The `prisma-postgres` state-store slug persists - across the storage change; series keyed on it read through the cutover. +- **No visible state database.** The Console shows only the user's own databases; the quota slot each stage's store consumed (ADR-0034's standing consequence) is returned, and the delete-the-state-database-by-hand footgun disappears with it. +- **Crash recovery trades instant for bounded.** The advisory lock freed the moment a crashed deploy's connection dropped; a crashed deploy's lease now blocks the stage until its TTL expires — up to 60 seconds. Accepted: rare case, small bound, and the retrying operator sees who holds the lease. +- **Contention behavior is preserved.** Fail fast, never queue, error names the holder. A `--wait` affordance can still layer over the same lease later without changing its semantics. +- **Lease loss is detected server-side within one extra request**, instead of within a client-side trust window. The stock client's fatal treatment of 409 is what makes this hold; if the client's retry policy ever changes, this property must be re-verified. +- **The routes are experimental, and a route move is a live break.** The state URL is baked into every published composer version and the platform serves one live API to all of them, so if the routes move, already installed versions fail at deploy time until each user upgrades. Alchemy's contract carries an unauthenticated `/version` probe that can detect contract drift, but detection only names the break — it does not prevent it. Accepted while the surface stabilizes; moving the routes is a platform decision that must weigh this cost. +- **No migration.** Legacy stages refuse to deploy until destroyed or deleted (see the deploying guide); their state databases are cleaned up by Branch deletion or by hand, never by this version's code. +- **Platform-side teardown still covers platform resources only.** State can track resources outside Prisma Cloud; deleting the Branch deletes the only record of them. Same documented limitation as ADR-0034, same shape. +- **Telemetry continuity.** The `prisma-postgres` state-store slug persists across the storage change; series keyed on it read through the cutover. ## Alternatives considered -- **Keep the database store** — rejected: two ADRs recorded the API as the end - state, the API now exists, and keeping both means maintaining a bespoke - store, its lock, and its proof suite alongside a stock client. -- **A composer-written API client** — rejected: the server implements - Alchemy's contract verbatim, so the stock client is the contract; a bespoke - client could only drift from it. -- **Port ADR-0010's liveness checker to the API** — rejected: the server - checks the lease on every operation; a client-side pre-check would add a - round-trip to re-derive what the next request reports anyway. -- **Migrate legacy state into the API** — rejected: destroy-then-redeploy is - the recorded precedent (ADR-0034), the affected population is pre-GA, and - migration tooling would have to be proven against every legacy store - generation for a one-time event. -- **Queue on lease contention** — rejected again for the reasons in ADR-0010: - a hanging deploy is worse than a clear refusal; waiting can be added as an - explicit flag later. +- **Keep the database store** — rejected: two ADRs recorded the API as the end state, the API now exists, and keeping both means maintaining a bespoke store, its lock, and its proof suite alongside a stock client. +- **A composer-written API client** — rejected: the server implements Alchemy's contract verbatim, so the stock client is the contract; a bespoke client could only drift from it. +- **Port ADR-0010's liveness checker to the API** — rejected: the server checks the lease on every operation; a client-side pre-check would add a round-trip to re-derive what the next request reports anyway. +- **Migrate legacy state into the API** — rejected: destroy-then-redeploy is the recorded precedent (ADR-0034), the affected population is pre-GA, and migration tooling would have to be proven against every legacy store generation for a one-time event. +- **Queue on lease contention** — rejected again for the reasons in ADR-0010: a hanging deploy is worse than a clear refusal; waiting can be added as an explicit flag later. ## Related -- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) / - [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — the two - prior stores; both named this API as the end state. ADR-0034's - Branch-scoping and lifetime reasoning carries over unchanged. -- [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — the advisory - lock this lease supersedes; its contention UX survives. -- [ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) — unchanged: - the target supplies this layer. -- [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) — closed as - obsolete; its pick-up trigger fired. -- prisma/pdp-control-plane #4816 (schema) and #4817 (API) — the server - implementation of the state routes and the lease. -- [`../03-domain-model/layering.md`](../03-domain-model/layering.md) — the - provisioning-state spectrum this advances. +- [ADR-0009](ADR-0009-deploy-state-is-hosted-in-the-workspace.md) / [ADR-0034](ADR-0034-deploy-state-lives-in-the-stage-branch.md) — the two prior stores; both named this API as the end state. ADR-0034's Branch-scoping and lifetime reasoning carries over unchanged. +- [ADR-0010](ADR-0010-deploys-hold-a-session-advisory-lock.md) — the advisory lock this lease supersedes; its contention UX survives. +- [ADR-0011](ADR-0011-targets-supply-the-deploy-state-layer.md) — unchanged: the target supplies this layer. +- [ADR-0012](ADR-0012-the-state-store-speaks-sql-directly.md) — closed as obsolete; its pick-up trigger fired. +- prisma/pdp-control-plane #4816 (schema) and #4817 (API) — the server implementation of the state routes and the lease. +- [`../03-domain-model/layering.md`](../03-domain-model/layering.md) — the provisioning-state spectrum this advances. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index 5b43afcf6..d76d80bb0 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -36,16 +36,7 @@ build produced: turbo run build && prisma-composer deploy module.ts ``` -Deploy state (what's already provisioned, so re-deploys diff instead of -recreate) is stored with the environment it describes, not on your machine — -that's the `prismaState()` line in `prisma-composer.config.ts`. The platform -hosts each environment's state behind its API, scoped to that environment's -Branch inside the app's Project; nothing extra shows up in the Console. -Everyone deploying the app shares it, your laptop and CI see the same world, -and two concurrent deploys of the same environment lock each other out -instead of corrupting it: the second one fails immediately with a message -naming who holds the deploy lease. Destroying or deleting an environment -removes its state with it. +Deploy state (what's already provisioned, so re-deploys diff instead of recreate) is stored with the environment it describes, not on your machine — that's the `prismaState()` line in `prisma-composer.config.ts`. The platform hosts each environment's state behind its API, scoped to that environment's Branch inside the app's Project; nothing extra shows up in the Console. Everyone deploying the app shares it, your laptop and CI see the same world, and two concurrent deploys of the same environment lock each other out instead of corrupting it: the second one fails immediately with a message naming who holds the deploy lease. Destroying or deleting an environment removes its state with it. ## Production and stages @@ -116,12 +107,7 @@ prisma-composer destroy module.ts --stage staging # staging only; production un prisma-composer destroy module.ts --production # production's resources ``` -`--stage` and `--production` together is an error too. Destroying a stage -removes its resources, then deletes its Branch — and the Branch takes the -stage's deploy state with it; destroying production removes the resources, -but the production Branch itself always survives. -Destroy never creates: tearing down a stage that was never deployed fails -with "nothing deployed" rather than provisioning one first. +`--stage` and `--production` together is an error too. Destroying a stage removes its resources, then deletes its Branch — and the Branch takes the stage's deploy state with it; destroying production removes the resources, but the production Branch itself always survives. Destroy never creates: tearing down a stage that was never deployed fails with "nothing deployed" rather than provisioning one first. Destroying production also removes the app's Project once nothing is left in it, so hand-run stacks don't pile up as empty Projects in your workspace. If @@ -266,41 +252,21 @@ footguns with diagnoses, kept current as we hit them. ## Upgrading from an older state store -Older framework versions stored deploy state differently: first in a -workspace-level `prisma-composer-state` project, later in a small -`prisma-composer-state` database on each environment's Branch. The current -version stores state behind the platform's API and never reads either legacy -store — there is no automated migration. The cutover is the same for both -generations: destroy, upgrade, redeploy. - -Deploying over a live legacy environment is refused up front. The deploy -finds no API-hosted state but sees resources (apps, databases, or buckets) -already on the Branch, and -stops with an error saying the stage predates the platform state API — -instead of blindly recreating every resource and failing halfway. Cut over -per app: - -1. On the **old** framework version, destroy every environment: each - `--stage`, then `--production`. (Equivalent: delete the stage's Branch — - or the whole Project, for production — in the Console or via the - Management API.) +Older framework versions stored deploy state differently: first in a workspace-level `prisma-composer-state` project, later in a small `prisma-composer-state` database on each environment's Branch. The current version stores state behind the platform's API and never reads either legacy store — there is no automated migration. The cutover is the same for both generations: destroy, upgrade, redeploy. + +Deploying over a live legacy environment is refused up front. The deploy finds no API-hosted state but sees resources (apps, databases, or buckets) already on the Branch, and stops with an error saying the stage predates the platform state API — instead of blindly recreating every resource and failing halfway. Cut over per app: + +1. On the **old** framework version, destroy every environment: each `--stage`, then `--production`. (Equivalent: delete the stage's Branch — or the whole Project, for production — in the Console or via the Management API.) 2. Upgrade the framework packages. -3. Deploy again — each environment starts fresh, hosted behind the platform - state API. +3. Deploy again — each environment starts fresh, hosted behind the platform state API. Recreated apps get new generated URLs; anything pointing at the old ones needs updating. -Legacy leftovers are inert and safe to remove whenever convenient — nothing -reads them after the upgrade, and each costs only a database quota slot: +Legacy leftovers are inert and safe to remove whenever convenient — nothing reads them after the upgrade, and each costs only a database quota slot: -- Branch-hosted generation: destroying on the old version already removed - the environment's `prisma-composer-state` database. If you skipped that - and deleted Branches by hand instead, each Branch took its database with - it — but production's, on the default Branch, survives: delete it in the - Console. -- Workspace-hosted generation: delete the workspace-level - `prisma-composer-state` project from the Console. +- Branch-hosted generation: destroying on the old version already removed the environment's `prisma-composer-state` database. If you skipped that and deleted Branches by hand instead, each Branch took its database with it — but production's, on the default Branch, survives: delete it in the Console. +- Workspace-hosted generation: delete the workspace-level `prisma-composer-state` project from the Console. ## Driving deploys from code From 1801bbe9af9d4dea3a24fc1d7b11424898cc7ea8 Mon Sep 17 00:00:00 2001 From: willbot Date: Sun, 9 Aug 2026 13:38:20 +0200 Subject: [PATCH 10/10] docs: scope the no-race claim to a live lease; separate the three teardown state lifetimes Signed-off-by: willbot Signed-off-by: Will Madden --- docs/design/03-domain-model/layering.md | 2 +- docs/guides/deploying.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/03-domain-model/layering.md b/docs/design/03-domain-model/layering.md index ee5856d85..38b804964 100644 --- a/docs/design/03-domain-model/layering.md +++ b/docs/design/03-domain-model/layering.md @@ -124,7 +124,7 @@ Provisioning runs through **Alchemy's engine**, invoked from the client or a pri - **Local** — Alchemy's local or Cloudflare-backed state. Fine for a solo developer; nothing else needs to see it. -- **Platform-hosted** — the Management API implements Alchemy's own HTTP `StateApi` wire contract per Branch of the app's own Project (`…/branches/{branchId}/alchemy-state`, ADR-0045), and the framework's state layer (`@internal/lowering/state`) is Alchemy's stock HTTP client pointed at it — Pulumi/Terraform-Cloud-style hosted state, native to the Workspace → Project → Branch hierarchy, with no BYO-state bootstrap and no visible state database. A deployer needs nothing beyond the service token it already has, and the state's lifetime is the environment's — deleting the Branch or Project deletes it. Concurrency is a server-side per-`(stack, stage)` deploy lease held around the run, so two deployers can never race the same stack. `prismaCloud()` supplies this as the default deploy state for every service and Module; an explicit state layer always overrides it. Like hosted-state backends generally, it also holds state for the user's BYO resources in other clouds, and it lets the platform answer "what's provisioned in this project" natively (the platform side of the inspectable-topology goal). +- **Platform-hosted** — the Management API implements Alchemy's own HTTP `StateApi` wire contract per Branch of the app's own Project (`…/branches/{branchId}/alchemy-state`, ADR-0045), and the framework's state layer (`@internal/lowering/state`) is Alchemy's stock HTTP client pointed at it — Pulumi/Terraform-Cloud-style hosted state, native to the Workspace → Project → Branch hierarchy, with no BYO-state bootstrap and no visible state database. A deployer needs nothing beyond the service token it already has, and the state's lifetime is the environment's — deleting the Branch or Project deletes it. Concurrency is a server-side per-`(stack, stage)` deploy lease held around the run: while a lease is live a second deploy of the same stack and stage is refused, and a run that outlives its lease (a crashed deploy's lease expires after its TTL) has every further state operation rejected by the server — so a takeover deploy can proceed without the stale run corrupting shared state. `prismaCloud()` supplies this as the default deploy state for every service and Module; an explicit state layer always overrides it. Like hosted-state backends generally, it also holds state for the user's BYO resources in other clouds, and it lets the platform answer "what's provisioned in this project" natively (the platform side of the inspectable-topology goal). - **Server-side runs** — the platform executes the apply loop itself (git-push-style deploys). With state already platform-hosted, moving the engine server-side is incremental — the same evolution Pulumi/Terraform Cloud followed. ## Open questions diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index d76d80bb0..250d23635 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -36,7 +36,7 @@ build produced: turbo run build && prisma-composer deploy module.ts ``` -Deploy state (what's already provisioned, so re-deploys diff instead of recreate) is stored with the environment it describes, not on your machine — that's the `prismaState()` line in `prisma-composer.config.ts`. The platform hosts each environment's state behind its API, scoped to that environment's Branch inside the app's Project; nothing extra shows up in the Console. Everyone deploying the app shares it, your laptop and CI see the same world, and two concurrent deploys of the same environment lock each other out instead of corrupting it: the second one fails immediately with a message naming who holds the deploy lease. Destroying or deleting an environment removes its state with it. +Deploy state (what's already provisioned, so re-deploys diff instead of recreate) is stored with the environment it describes, not on your machine — that's the `prismaState()` line in `prisma-composer.config.ts`. The platform hosts each environment's state behind its API, scoped to that environment's Branch inside the app's Project; nothing extra shows up in the Console. Everyone deploying the app shares it, your laptop and CI see the same world, and two concurrent deploys of the same environment lock each other out instead of corrupting it: while one holds the deploy lease, the second fails immediately with a message naming the holder. If a deploy crashes, its lease expires (about a minute) and the next deploy takes over; a run that outlives its lease has every state operation rejected by the platform, so it can't corrupt the takeover's state. State lives and dies with its environment: deleting a stage's Branch — or the whole Project — removes that environment's state with it (production's state lifetime is spelled out under Destroying below). ## Production and stages @@ -107,7 +107,7 @@ prisma-composer destroy module.ts --stage staging # staging only; production un prisma-composer destroy module.ts --production # production's resources ``` -`--stage` and `--production` together is an error too. Destroying a stage removes its resources, then deletes its Branch — and the Branch takes the stage's deploy state with it; destroying production removes the resources, but the production Branch itself always survives. Destroy never creates: tearing down a stage that was never deployed fails with "nothing deployed" rather than provisioning one first. +`--stage` and `--production` together is an error too. The three teardown shapes differ in what happens to state. Destroying a **stage** removes its resources, then deletes its Branch — and the Branch takes the stage's deploy state with it. Destroying **production** removes the resources and empties production's deploy state as it goes, but the production Branch survives, so an emptied state scope remains until the Project itself is removed. Deleting the **Project** (below, or from the Console) removes every Branch and all state in one stroke. Destroy never creates: tearing down a stage that was never deployed fails with "nothing deployed" rather than provisioning one first. Destroying production also removes the app's Project once nothing is left in it, so hand-run stacks don't pile up as empty Projects in your workspace. If