From 1897f6b9796cfb840db42f2c6248b7c758520942 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 12:34:08 +0200 Subject: [PATCH 01/18] feat(stack): add managed stack persistence --- packages/stack/README.md | 28 + packages/stack/docs/architecture.md | 60 +- packages/stack/package.json | 7 +- packages/stack/src/entrypoints.unit.test.ts | 13 + packages/stack/src/managed-bun.ts | 35 + packages/stack/src/managed-node.ts | 35 + packages/stack/src/managed-paths.unit.test.ts | 32 + .../src/managed-service.integration.test.ts | 431 ++++++++++ packages/stack/src/managed.ts | 5 + packages/stack/src/managed/identity.ts | 124 +++ packages/stack/src/managed/model.ts | 206 +++++ packages/stack/src/managed/paths.ts | 51 ++ packages/stack/src/managed/repository.ts | 489 ++++++++++++ packages/stack/src/managed/service.ts | 346 ++++++++ packages/stack/src/managed/sqlite-bun.ts | 39 + packages/stack/src/managed/sqlite-node.ts | 39 + packages/stack/src/managed/sqlite.ts | 750 ++++++++++++++++++ packages/stack/src/testing.ts | 1 + 18 files changed, 2686 insertions(+), 5 deletions(-) create mode 100644 packages/stack/src/managed-bun.ts create mode 100644 packages/stack/src/managed-node.ts create mode 100644 packages/stack/src/managed-paths.unit.test.ts create mode 100644 packages/stack/src/managed-service.integration.test.ts create mode 100644 packages/stack/src/managed.ts create mode 100644 packages/stack/src/managed/identity.ts create mode 100644 packages/stack/src/managed/model.ts create mode 100644 packages/stack/src/managed/paths.ts create mode 100644 packages/stack/src/managed/repository.ts create mode 100644 packages/stack/src/managed/service.ts create mode 100644 packages/stack/src/managed/sqlite-bun.ts create mode 100644 packages/stack/src/managed/sqlite-node.ts create mode 100644 packages/stack/src/managed/sqlite.ts diff --git a/packages/stack/README.md b/packages/stack/README.md index 6d056e4908..ab5f40dd43 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -2,6 +2,11 @@ Programmatic local Supabase stack for TypeScript. Create a local Supabase runtime from code, then control lifecycle, status, and logs through a small async handle. +The package also exposes `@supabase/stack/managed` for applications that need durable, +system-aware stack identity and discovery. The managed surface is intentionally separate from +`createStack()`: direct stacks never inspect Git, create workspace markers, or mutate the global +registry. + ## Features - **Single entry point** -- `createStack()` resolves config and returns a handle; `start()` prepares assets, starts services, and waits for readiness @@ -34,6 +39,29 @@ const supabase = createClient(stack.url, stack.publishableKey); await stack.dispose(); ``` +### Managed ordinary-folder state + +```typescript +import { createManagedStackService } from "@supabase/stack/managed"; + +const managed = createManagedStackService(); +const result = await managed.provisionOrdinaryStack({ + workspacePath: "/absolute/project", + configuration: { + runtimeRequest: "docker", + serviceVersions: { postgres: "17.6.1.143" }, + }, +}); + +console.log(result.stack.id, result.stack.paths.data); +managed.close(); +``` + +Managed state uses opaque project, checkout, context, and stack UUIDs. A non-Git workspace stores +only its three identity UUIDs in `.supabase/identity.json`; mutable state, logs, runtime metadata, +ports, and lifecycle ownership live under the user-level managed state root. Callers can inject an +in-memory repository or an isolated state root for tests. + ### With explicit config ```typescript diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 847f705351..d24eef2846 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -7,12 +7,14 @@ delegated to [`@supabase/process-compose`](../../process-compose/docs/architectu ## Public entrypoints -The package exposes two levels of Interface: +The package exposes three levels of Interface: - `@supabase/stack` selects `bun.ts` or `node.ts` through export conditions and exposes the Promise-oriented `createStack()` / `StackHandle` Interface plus prefetch helpers. - `@supabase/stack/effect` selects a runtime Adapter through the same export conditions and exposes Effect Interfaces plus platform-bound layer factories used by the CLI and advanced callers. +- `@supabase/stack/managed` selects the Node or Bun SQLite Adapter and exposes managed identity, + discovery, persistence, and lifecycle coordination. Its repository can be replaced by a caller. - `@supabase/stack/testing` exposes only the service tags needed to replace daemon transport in consumer tests. Runtime implementation tags do not leak through the root or Effect barrels. @@ -20,6 +22,11 @@ Internal runtime Adapters provide Effect filesystem, path, child-process, HTTP-s socket HTTP implementations. `createStack.ts` and the layer factories remain platform-agnostic; the conditional root and Effect entries bind them to their selected runtime. +The direct and managed surfaces compose in one direction only: managed policy resolves one opaque +stack identity and concrete roots, ports, and runtime selection, then a caller may pass those +resolved values to the core runtime. The core runtime never discovers workspaces or opens the +global registry. + ```mermaid flowchart LR Input["StackConfig"] --> Resolve["StackConfigResolver"] @@ -267,9 +274,51 @@ Unix-socket transport, not the public Supabase API proxy. See [detach mode](./detach-mode.md) for paths, process startup, and compiled executable dispatch. -## Managed paths +## Managed identity and state + +The managed surface owns a versioned SQLite registry with separate records for projects, +checkouts, checkout locations, development contexts, stacks, port reservations, and operations. +The public repository contract contains no SQLite types, so the same service runs with the +in-memory test repository and the Node or Bun persistent Adapter. + +For an ordinary non-Git folder, the first mutating managed operation atomically publishes: + +```text +/.supabase/identity.json + version + projectId + checkoutId + contextId +``` + +No mutable runtime state or credential value is stored in that marker. Read-only discovery does +not create it. The registry stores only an opaque credential reference, never resolved plaintext +credentials. -With the default cache root (`~/.supabase`), durable data is project-keyed: +The managed state root is explicitly injectable. Otherwise it resolves from `SUPABASE_HOME` or +the platform application-state directory. Every physical stack path is keyed only by its opaque +stack UUID: + +```text +/ + registry-v1.sqlite3 + stacks// + data/ + logs/ + runtime/ +``` + +Stack publication and operation claims are transactional. A new stack remains `pending` while its +directories and caller-supplied initialization are validated, then becomes `active` atomically. +Concurrent callers resolve the published record rather than creating aliases. Recovery retains an +abandoned claim until a runtime inspector reports the actual running or stopped state. Explicit +deletion safely stops, tombstones, and removes only the selected stack root; prune removes checkout +location metadata only. + +## Legacy daemon paths + +The pre-managed daemon implementation still reads its project-keyed state as a legacy/bootstrap +input for later CLI integration: ```text /projects//stacks// @@ -292,11 +341,16 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l `/stacks/`; managed daemon callers may not directly override individual `stackRoot` or `runtimeRoot` values. +These path hashes and stack-name directories are not identities in the new managed model and must +not be used for new managed records. + ## Runtime entrypoints and exports - `bun.ts` and `node.ts` are root export-condition targets. - `effect-bun.ts` and `effect-node.ts` are Effect export-condition targets. They bind foreground, daemon, and Unix-socket layers without exposing raw platform factories or bootstrap paths. +- `managed-bun.ts` and `managed-node.ts` bind the same storage-independent managed service to the + runtime's built-in SQLite implementation. - `daemon-bun.ts` is exported as `@supabase/stack/daemon-bun` so the compiled CLI can dispatch to it in-process. - `daemon-node.ts` is intentionally not a package export. The internal Node platform Adapter diff --git a/packages/stack/package.json b/packages/stack/package.json index d8c3f695d8..fbc048c6aa 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -12,6 +12,10 @@ "bun": "./src/effect-bun.ts", "default": "./src/effect-node.ts" }, + "./managed": { + "bun": "./src/managed-bun.ts", + "default": "./src/managed-node.ts" + }, "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, @@ -56,8 +60,7 @@ "oxlint-tsgolint" ], "ignoreBinaries": [ - "nx", - "ps" + "nx" ] } } diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 30fa2fdd75..8eceae5dec 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -7,6 +7,7 @@ import * as bunRoot from "./bun.ts"; import * as bunEffect from "./effect-bun.ts"; import * as nodeEffect from "./effect-node.ts"; import * as nodeRoot from "./node.ts"; +import * as managed from "./managed-bun.ts"; import type { StackHandle } from "./createStack.ts"; import type { Stack } from "./Stack.ts"; import * as testing from "./testing.ts"; @@ -40,6 +41,10 @@ describe("@supabase/stack entrypoints", () => { bun: "./src/effect-bun.ts", default: "./src/effect-node.ts", }, + "./managed": { + bun: "./src/managed-bun.ts", + default: "./src/managed-node.ts", + }, "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts", }); @@ -55,6 +60,13 @@ describe("@supabase/stack entrypoints", () => { expectTypeOf(bunRoot.createStack).returns.toEqualTypeOf>(); }); + it("exposes managed policy through its own entrypoint", () => { + expect(managed).toHaveProperty("createManagedStackService"); + expect(managed).toHaveProperty("makeManagedStackService"); + expect(managed).toHaveProperty("openBunSqliteManagedStackRepository"); + expect(nodeRoot).not.toHaveProperty("createManagedStackService"); + }); + it("binds consumer Effect layers without exposing implementation tags", () => { expectTypeOf(nodeEffect.foregroundLayer).returns.toEqualTypeOf>(); expectTypeOf(bunEffect.foregroundLayer).returns.toEqualTypeOf>(); @@ -74,6 +86,7 @@ describe("@supabase/stack entrypoints", () => { expect(Object.keys(testing).sort()).toEqual([ "DaemonServer", "UnixHttpClient", + "createInMemoryManagedStackRepository", "managedNativePlatformByNodeTarget", "managedNativePlatformFromNode", "managedNativeServiceMatrix", diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts new file mode 100644 index 0000000000..bd180634ba --- /dev/null +++ b/packages/stack/src/managed-bun.ts @@ -0,0 +1,35 @@ +import { managedRegistryPath, resolveManagedStateRoot } from "./managed/paths.ts"; +import type { ManagedStackRepository } from "./managed/repository.ts"; +import { makeManagedStackService } from "./managed/service.ts"; +import { openBunSqliteManagedStackRepository } from "./managed/sqlite-bun.ts"; + +export * from "./managed.ts"; +export { openBunSqliteManagedStackRepository }; + +export interface CreateManagedStackServiceOptions { + readonly stateRoot?: string; + readonly repository?: ManagedStackRepository; + readonly env?: Readonly>; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; + readonly idFactory?: () => string; + readonly clock?: () => Date; + readonly ownerPid?: number; + readonly publicationTimeoutMs?: number; + readonly publicationPollMs?: number; +} + +export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => { + const stateRoot = resolveManagedStateRoot(options); + const repository = + options.repository ?? openBunSqliteManagedStackRepository(managedRegistryPath(stateRoot)); + return makeManagedStackService({ + repository, + stateRoot, + idFactory: options.idFactory, + clock: options.clock, + ownerPid: options.ownerPid, + publicationTimeoutMs: options.publicationTimeoutMs, + publicationPollMs: options.publicationPollMs, + }); +}; diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts new file mode 100644 index 0000000000..0706036db1 --- /dev/null +++ b/packages/stack/src/managed-node.ts @@ -0,0 +1,35 @@ +import { managedRegistryPath, resolveManagedStateRoot } from "./managed/paths.ts"; +import type { ManagedStackRepository } from "./managed/repository.ts"; +import { makeManagedStackService } from "./managed/service.ts"; +import { openNodeSqliteManagedStackRepository } from "./managed/sqlite-node.ts"; + +export * from "./managed.ts"; +export { openNodeSqliteManagedStackRepository }; + +export interface CreateManagedStackServiceOptions { + readonly stateRoot?: string; + readonly repository?: ManagedStackRepository; + readonly env?: Readonly>; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; + readonly idFactory?: () => string; + readonly clock?: () => Date; + readonly ownerPid?: number; + readonly publicationTimeoutMs?: number; + readonly publicationPollMs?: number; +} + +export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => { + const stateRoot = resolveManagedStateRoot(options); + const repository = + options.repository ?? openNodeSqliteManagedStackRepository(managedRegistryPath(stateRoot)); + return makeManagedStackService({ + repository, + stateRoot, + idFactory: options.idFactory, + clock: options.clock, + ownerPid: options.ownerPid, + publicationTimeoutMs: options.publicationTimeoutMs, + publicationPollMs: options.publicationPollMs, + }); +}; diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts new file mode 100644 index 0000000000..65828fdf41 --- /dev/null +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { managedStackPaths, resolveManagedStateRoot } from "./managed/paths.ts"; + +describe("managed paths", () => { + it("isolates managed records beneath SUPABASE_HOME", () => { + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: "/configured/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/configured/supabase/managed"); + }); + + it("uses platform application-state directories by default", () => { + expect(resolveManagedStateRoot({ env: {}, homeDir: "/home/user", platform: "linux" })).toBe( + "/home/user/.local/state/supabase/managed", + ); + expect(resolveManagedStateRoot({ env: {}, homeDir: "/Users/user", platform: "darwin" })).toBe( + "/Users/user/Library/Application Support/supabase/managed", + ); + }); + + it("keys every mutable stack path by opaque stack ID", () => { + expect(managedStackPaths("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d")).toEqual({ + root: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d", + data: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/data", + logs: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/logs", + runtime: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/runtime", + }); + }); +}); diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts new file mode 100644 index 0000000000..aa4e5d0410 --- /dev/null +++ b/packages/stack/src/managed-service.integration.test.ts @@ -0,0 +1,431 @@ +import { Database } from "bun:sqlite"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { managedStackContractFixtures } from "./managed-stack-contract.ts"; +import { ordinaryWorkspaceIdentityPath } from "./managed/paths.ts"; +import { + InvalidManagedIdentityError, + ManagedPortReservationError, + ManagedStackInitializationError, + UnsupportedManagedRegistryVersionError, +} from "./managed/model.ts"; +import { createInMemoryManagedStackRepository } from "./managed/repository.ts"; +import { makeManagedStackService, type ManagedStackService } from "./managed/service.ts"; +import { openBunSqliteManagedStackRepository } from "./managed/sqlite-bun.ts"; + +const temporaryRoots: Array = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const makeRoot = (): string => { + const root = mkdtempSync(join(tmpdir(), "managed-stack-test-")); + temporaryRoots.push(root); + return root; +}; + +const makeWorkspace = (root: string, name = "workspace"): string => { + const workspace = join(root, name); + mkdirSync(workspace, { recursive: true }); + return workspace; +}; + +const makeInMemoryService = (root: string): ManagedStackService => + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + publicationPollMs: 1, + }); + +const makePersistentService = (root: string): ManagedStackService => { + const stateRoot = join(root, "managed"); + return makeManagedStackService({ + repository: openBunSqliteManagedStackRepository(join(stateRoot, "registry-v1.sqlite3")), + stateRoot, + publicationPollMs: 1, + }); +}; + +const fixture = (id: string) => { + const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); + if (scenario === undefined) { + throw new Error(`Missing managed stack contract fixture ${id}`); + } + return scenario; +}; + +describe("ordinary-folder managed stack contract", () => { + it("keeps read-only discovery registration-free", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makeInMemoryService(root); + + const result = await service.inspectOrdinaryWorkspace(workspace); + + expect(result).toEqual({ registered: false, stacks: [] }); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); + expect(service.repository.listStacks()).toEqual([]); + expect(service.repository.listCheckoutLocations()).toEqual([]); + }); + + it("fails safely on an unknown newer workspace identity marker", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makeInMemoryService(root); + const markerPath = ordinaryWorkspaceIdentityPath(workspace); + mkdirSync(join(workspace, ".supabase")); + writeFileSync( + markerPath, + JSON.stringify({ + version: 999, + projectId: crypto.randomUUID(), + checkoutId: crypto.randomUUID(), + contextId: crypto.randomUUID(), + }), + ); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(InvalidManagedIdentityError); + expect(service.repository.listStacks()).toEqual([]); + expect(service.repository.listCheckoutLocations()).toEqual([]); + }); + + it("executes the first-start and persisted-identity M1 fixtures against SQLite", async () => { + const firstStart = fixture("identity.non-git-folder-first-start-persists-identity"); + const recoveredStart = fixture("identity.non-git-folder-recovers-persisted-identity"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makePersistentService(root); + + const created = await service.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { + runtimeRequest: "docker", + runtime: "docker", + ports: [{ key: "api.port", port: 54_321, intent: "automatic" }], + serviceVersions: { postgres: "17.6.1" }, + runtimeMetadata: { + pid: process.pid, + socketPath: join(root, "daemon.sock"), + processIds: { postgres: process.pid }, + containerIds: { auth: "container-auth" }, + }, + configFingerprint: "config-v1", + credentialsReference: "credentials-v1", + }, + }); + + expect(created.outcome).toBe(firstStart.expected.outcome); + expect(created.identityMarkerCreated).toBe(true); + expect(created.stack.status).toBe("active"); + expect(created.stack.paths.root).toBe(join(service.stateRoot, "stacks", created.stack.id)); + expect(created.stack.paths.root.startsWith(workspace)).toBe(false); + expect(created.stack.ports).toEqual([{ key: "api.port", port: 54_321, intent: "automatic" }]); + expect(created.stack.serviceVersions).toEqual({ postgres: "17.6.1" }); + expect(created.stack.runtimeMetadata).toEqual({ + pid: process.pid, + socketPath: join(root, "daemon.sock"), + processIds: { postgres: process.pid }, + containerIds: { auth: "container-auth" }, + }); + expect(existsSync(created.stack.paths.data)).toBe(true); + expect(existsSync(created.stack.paths.logs)).toBe(true); + expect(existsSync(created.stack.paths.runtime)).toBe(true); + + const marker = JSON.parse(readFileSync(ordinaryWorkspaceIdentityPath(workspace), "utf8")); + expect(Object.keys(marker).sort()).toEqual(["checkoutId", "contextId", "projectId", "version"]); + expect(marker).toMatchObject({ + projectId: created.selection.projectId, + checkoutId: created.selection.checkoutId, + contextId: created.selection.contextId, + }); + + service.close(); + const reopened = makePersistentService(root); + const reused = await reopened.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(reused.outcome).toBe(recoveredStart.expected.outcome); + expect(reused.identityMarkerCreated).toBe(false); + expect(reused.selection).toEqual(created.selection); + expect(reused.stack.ports).toEqual(created.stack.ports); + expect(reopened.listStacks()).toHaveLength(1); + reopened.close(); + + const registry = new Database(join(root, "managed", "registry-v1.sqlite3")); + const columns = registry.query("PRAGMA table_info(stacks)").all(); + const columnNames = columns.map((column) => + typeof column === "object" && column !== null ? Reflect.get(column, "name") : undefined, + ); + expect(columnNames).not.toContain("credentials"); + expect(columnNames).not.toContain("secret_key"); + expect(columnNames).toContain("credentials_reference"); + registry.close(); + }); + + it("accepts an injected repository and isolated state root without CLI ownership", async () => { + const contract = fixture("api-boundary.managed-api-accepts-injected-repository"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + const stateRoot = join(root, "isolated-managed-state"); + const service = makeManagedStackService({ repository, stateRoot }); + + const result = await service.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(contract.expected.outcome).toBe("create"); + expect(result.outcome).toBe("create"); + expect(service.repository).toBe(repository); + expect(result.stack.paths.root.startsWith(stateRoot)).toBe(true); + }); + + it("publishes one stack when two callers provision the same identity concurrently", async () => { + const contract = fixture("identity.concurrent-create-publishes-once"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makePersistentService(root); + let releaseInitialization: () => void = () => {}; + const initializationGate = new Promise((resolve) => { + releaseInitialization = resolve; + }); + let initializerCalls = 0; + + const first = service.provisionOrdinaryStack({ + workspacePath: workspace, + initialize: async () => { + initializerCalls += 1; + await initializationGate; + }, + }); + while (service.repository.listStacks().length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const second = service.provisionOrdinaryStack({ workspacePath: workspace }); + releaseInitialization(); + const results = await Promise.all([first, second]); + + expect(contract.expected.outcome).toBe("create"); + expect(results.map((result) => result.outcome).sort()).toEqual(["create", "reuse"]); + expect(new Set(results.map((result) => result.stack.id))).toHaveProperty("size", 1); + expect(initializerCalls).toBe(1); + expect(service.repository.listStacks()).toHaveLength(1); + service.close(); + }); + + it("rolls back failed initialization and makes the same start retryable", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makePersistentService(root); + let failedRoot: string | undefined; + + await expect( + service.provisionOrdinaryStack({ + workspacePath: workspace, + initialize: async (stack) => { + failedRoot = stack.paths.root; + throw new Error("initialization failed"); + }, + }), + ).rejects.toBeInstanceOf(ManagedStackInitializationError); + + expect(failedRoot).toBeDefined(); + expect(existsSync(failedRoot ?? "")).toBe(false); + expect(service.listStacks()).toEqual([]); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(true); + + const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(retried.outcome).toBe("create"); + expect(service.listStacks()).toHaveLength(1); + service.close(); + }); +}); + +describe("managed repository and lifecycle", () => { + for (const adapter of ["in-memory", "sqlite"] as const) { + it(`keeps repository decisions storage-agnostic for the ${adapter} adapter`, async () => { + const contract = fixture("api-boundary.repository-contract-is-storage-agnostic"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + + const created = await service.provisionOrdinaryStack({ workspacePath: workspace }); + const reused = await service.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(contract.expected.outcome).toBe("report"); + expect(created.outcome).toBe("create"); + expect(reused.outcome).toBe("reuse"); + expect(reused.selection).toEqual(created.selection); + service.close(); + }); + } + + it("persists stack configuration and reserves ports globally", async () => { + const root = makeRoot(); + const service = makePersistentService(root); + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "second"), + }); + + const configured = await service.updateStack(first.stack.id, { + runtimeRequest: "native", + runtime: "native", + lifecycle: "running", + ports: [{ key: "db.port", port: 54_322, intent: "automatic" }], + serviceVersions: { postgres: "17.6.1.143", storage: "1.28.0" }, + runtimeMetadata: { + pid: 42, + socketPath: "/tmp/managed.sock", + processIds: { postgres: 43 }, + containerIds: { storage: "storage-container" }, + }, + configFingerprint: "fingerprint-v2", + credentialsReference: "credential-record-v2", + }); + + expect(configured).toMatchObject({ + runtimeRequest: "native", + runtime: "native", + lifecycle: "running", + serviceVersions: { postgres: "17.6.1.143", storage: "1.28.0" }, + configFingerprint: "fingerprint-v2", + credentialsReference: "credential-record-v2", + }); + expect(configured.runtimeMetadata.processIds).toEqual({ postgres: 43 }); + + await expect( + service.updateStack(second.stack.id, { + ports: [{ key: "db.port", port: 54_322, intent: "exact" }], + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect(service.inspectStack(second.stack.id)?.ports).toEqual([]); + service.close(); + }); + + it("rolls back an in-memory registration when its initial port reservation conflicts", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root); + await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + configuration: { ports: [{ key: "api.port", port: 54_321, intent: "exact" }] }, + }); + const secondWorkspace = makeWorkspace(root, "second"); + + await expect( + service.provisionOrdinaryStack({ + workspacePath: secondWorkspace, + configuration: { ports: [{ key: "api.port", port: 54_321, intent: "exact" }] }, + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect(service.repository.listCheckoutLocations()).toHaveLength(1); + expect(service.listStacks()).toHaveLength(1); + + const retried = await service.provisionOrdinaryStack({ workspacePath: secondWorkspace }); + expect(retried.outcome).toBe("create"); + expect(service.repository.listCheckoutLocations()).toHaveLength(2); + }); + + it("requires actual runtime inspection before recovering an abandoned operation", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + throw new Error("Expected to claim an abandoned operation"); + } + service.repository.updateStack({ + stackId: created.stack.id, + operationToken: claimed.operation.token, + lifecycle: "starting", + now: "2026-08-11T00:00:01.000Z", + }); + + const unknown = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "unknown", + }); + expect(unknown.recovered).toEqual([]); + expect(unknown.retained).toEqual([claimed.operation]); + expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("starting"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + expect(reconciled.retained).toEqual([]); + expect(reconciled.recovered).toHaveLength(1); + expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("stopped"); + }); + + it("stops, tombstones, and reclaims one opaque stack ID idempotently", async () => { + const contract = fixture("reclamation.delete-repeat-is-idempotent"); + const root = makeRoot(); + const service = makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { lifecycle: "running" }, + }); + writeFileSync(join(created.stack.paths.data, "database"), "owned data"); + let stoppedStackId: string | undefined; + + const deleted = await service.deleteStack(created.stack.id, { + stop: async (stack) => { + stoppedStackId = stack.id; + }, + }); + const repeated = await service.deleteStack(created.stack.id); + + expect(deleted.outcome).toBe("delete"); + expect(stoppedStackId).toBe(created.stack.id); + expect(existsSync(created.stack.paths.root)).toBe(false); + expect(repeated.outcome).toBe(contract.expected.outcome); + expect(service.listStacks()).toEqual([]); + expect(service.listStacks({ includeTombstoned: true })).toHaveLength(1); + service.close(); + }); + + it("prunes checkout location metadata without touching stack data", async () => { + const contract = fixture("reclamation.prune-removes-metadata-only"); + const root = makeRoot(); + const service = makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const dataFile = join(created.stack.paths.data, "database"); + writeFileSync(dataFile, "preserve me"); + + const pruned = await service.pruneCheckoutLocations(() => true); + + expect(contract.expected.outcome).toBe("update"); + expect(pruned).toBe(1); + expect(service.repository.listCheckoutLocations()).toEqual([]); + expect(service.inspectStack(created.stack.id)?.status).toBe("active"); + expect(readFileSync(dataFile, "utf8")).toBe("preserve me"); + service.close(); + }); + + it("fails safely when a registry has a newer schema version", () => { + const root = makeRoot(); + const databasePath = join(root, "future.sqlite3"); + const database = new Database(databasePath, { create: true }); + database.exec("PRAGMA user_version = 999"); + database.close(); + + expect(() => openBunSqliteManagedStackRepository(databasePath)).toThrow( + UnsupportedManagedRegistryVersionError, + ); + }); +}); diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts new file mode 100644 index 0000000000..83665d8b98 --- /dev/null +++ b/packages/stack/src/managed.ts @@ -0,0 +1,5 @@ +export * from "./managed/identity.ts"; +export * from "./managed/model.ts"; +export * from "./managed/paths.ts"; +export * from "./managed/repository.ts"; +export * from "./managed/service.ts"; diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts new file mode 100644 index 0000000000..bb3311b30f --- /dev/null +++ b/packages/stack/src/managed/identity.ts @@ -0,0 +1,124 @@ +import { randomUUID } from "node:crypto"; +import { link, mkdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + InvalidManagedIdentityError, + ORDINARY_WORKSPACE_IDENTITY_VERSION, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; +import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const errorCode = (error: unknown): string | undefined => { + if (typeof error !== "object" || error === null) { + return undefined; + } + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +}; + +const identityField = (value: unknown, field: string): string => { + if (typeof value !== "object" || value === null) { + throw new InvalidManagedIdentityError("The ordinary workspace identity must be an object"); + } + const fieldValue = Reflect.get(value, field); + if (typeof fieldValue !== "string" || !UUID_PATTERN.test(fieldValue)) { + throw new InvalidManagedIdentityError(`${field} must be an opaque UUID`); + } + return fieldValue; +}; + +const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { + let value: unknown; + try { + value = JSON.parse(content); + } catch (cause: unknown) { + throw new InvalidManagedIdentityError(`The ordinary workspace identity is not JSON: ${cause}`); + } + if (typeof value !== "object" || value === null) { + throw new InvalidManagedIdentityError("The ordinary workspace identity must be an object"); + } + const version = Reflect.get(value, "version"); + if (version !== ORDINARY_WORKSPACE_IDENTITY_VERSION) { + throw new InvalidManagedIdentityError( + `Unsupported ordinary workspace identity version ${String(version)}`, + ); + } + return { + version, + projectId: identityField(value, "projectId"), + checkoutId: identityField(value, "checkoutId"), + contextId: identityField(value, "contextId"), + }; +}; + +export const canonicalizeOrdinaryWorkspacePath = async (workspacePath: string): Promise => { + const info = await stat(workspacePath); + if (!info.isDirectory()) { + throw new InvalidManagedIdentityError(`${workspacePath} is not a directory`); + } + return realpath(workspacePath); +}; + +export const readOrdinaryWorkspaceIdentity = async ( + workspacePath: string, +): Promise => { + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + try { + return decodeIdentity(await readFile(markerPath, "utf8")); + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return undefined; + } + throw error; + } +}; + +export interface EnsureOrdinaryWorkspaceIdentityResult { + readonly identity: OrdinaryWorkspaceIdentity; + readonly created: boolean; + readonly markerPath: string; +} + +export const ensureOrdinaryWorkspaceIdentity = async ( + workspacePath: string, + idFactory: () => string = randomUUID, +): Promise => { + const existing = await readOrdinaryWorkspaceIdentity(workspacePath); + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + if (existing !== undefined) { + return { identity: existing, created: false, markerPath }; + } + + const identity: OrdinaryWorkspaceIdentity = { + version: ORDINARY_WORKSPACE_IDENTITY_VERSION, + projectId: idFactory(), + checkoutId: idFactory(), + contextId: idFactory(), + }; + for (const id of [identity.projectId, identity.checkoutId, identity.contextId]) { + if (!UUID_PATTERN.test(id)) { + throw new InvalidManagedIdentityError(`Identity factory returned a non-UUID value: ${id}`); + } + } + + await mkdir(dirname(markerPath), { recursive: true }); + const temporaryPath = `${markerPath}.tmp.${idFactory()}`; + await writeFile(temporaryPath, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 }); + try { + await link(temporaryPath, markerPath); + return { identity, created: true, markerPath }; + } catch (error: unknown) { + if (errorCode(error) !== "EEXIST") { + throw error; + } + const winner = await readOrdinaryWorkspaceIdentity(workspacePath); + if (winner === undefined) { + throw new InvalidManagedIdentityError("Identity publication raced without a winning marker"); + } + return { identity: winner, created: false, markerPath }; + } finally { + await unlink(temporaryPath).catch(() => undefined); + } +}; diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts new file mode 100644 index 0000000000..dad398c928 --- /dev/null +++ b/packages/stack/src/managed/model.ts @@ -0,0 +1,206 @@ +export const MANAGED_REGISTRY_SCHEMA_VERSION = 1; +export const ORDINARY_WORKSPACE_IDENTITY_VERSION = 1; +export const DEFAULT_MANAGED_STACK_NAME = "default"; + +export type ManagedRuntimeRequest = "auto" | "docker" | "native"; +export type ManagedRuntime = "docker" | "native"; +export type ManagedStackStatus = "active" | "pending" | "tombstoned"; +export type ManagedStackLifecycle = "failed" | "running" | "starting" | "stopped" | "stopping"; +export type ManagedPortIntent = "automatic" | "exact"; +export type ManagedOperationKind = "delete" | "start" | "stop" | "update"; +export type ManagedOperationStatus = "active" | "completed" | "failed"; + +export interface OrdinaryWorkspaceIdentity { + readonly version: typeof ORDINARY_WORKSPACE_IDENTITY_VERSION; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; +} + +export interface ManagedStackPaths { + readonly root: string; + readonly data: string; + readonly logs: string; + readonly runtime: string; +} + +export interface ManagedPortAssignment { + readonly key: string; + readonly port: number; + readonly intent: ManagedPortIntent; +} + +export interface ManagedRuntimeMetadata { + readonly pid?: number; + readonly socketPath?: string; + readonly processIds: Readonly>; + readonly containerIds: Readonly>; +} + +export interface ManagedStackRecord { + readonly id: string; + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly name: string; + readonly status: ManagedStackStatus; + readonly lifecycle: ManagedStackLifecycle; + readonly runtimeRequest: ManagedRuntimeRequest; + readonly runtime?: ManagedRuntime; + readonly paths: ManagedStackPaths; + readonly ports: ReadonlyArray; + readonly serviceVersions: Readonly>; + readonly runtimeMetadata: ManagedRuntimeMetadata; + readonly configFingerprint?: string; + readonly credentialsReference?: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly tombstonedAt?: string; +} + +export interface ManagedOperationRecord { + readonly token: string; + readonly stackId: string; + readonly kind: ManagedOperationKind; + readonly status: ManagedOperationStatus; + readonly ownerPid?: number; + readonly startedAt: string; + readonly finishedAt?: string; + readonly error?: string; +} + +export interface ManagedCheckoutLocation { + readonly id: string; + readonly checkoutId: string; + readonly canonicalPath: string; + readonly lastSeenAt: string; +} + +export interface ManagedStackConfiguration { + readonly runtimeRequest?: ManagedRuntimeRequest; + readonly runtime?: ManagedRuntime; + readonly ports?: ReadonlyArray; + readonly serviceVersions?: Readonly>; + readonly runtimeMetadata?: ManagedRuntimeMetadata; + readonly lifecycle?: ManagedStackLifecycle; + readonly configFingerprint?: string; + readonly credentialsReference?: string; +} + +export interface ManagedStackSelection { + readonly projectId: string; + readonly checkoutId: string; + readonly contextId: string; + readonly stackId: string; + readonly stackName: string; +} + +export class ManagedStackError extends Error {} + +export class InvalidManagedIdentityError extends ManagedStackError { + readonly code = "INVALID_MANAGED_IDENTITY"; + + constructor(message: string) { + super(message); + this.name = "InvalidManagedIdentityError"; + } +} + +export class UnsupportedManagedRegistryVersionError extends ManagedStackError { + readonly code = "UNSUPPORTED_MANAGED_REGISTRY_VERSION"; + + constructor( + readonly found: number, + readonly supported: number, + ) { + super(`Managed registry version ${found} is newer than supported version ${supported}`); + this.name = "UnsupportedManagedRegistryVersionError"; + } +} + +export class DuplicateManagedIdentityError extends ManagedStackError { + readonly code = "DUPLICATE_MANAGED_IDENTITY"; + + constructor( + readonly checkoutId: string, + readonly existingPath: string, + readonly requestedPath: string, + ) { + super( + `Checkout ${checkoutId} is already registered at ${existingPath}; refusing a second claim from ${requestedPath}`, + ); + this.name = "DuplicateManagedIdentityError"; + } +} + +export class ManagedStackNotFoundError extends ManagedStackError { + readonly code = "MANAGED_STACK_NOT_FOUND"; + + constructor(readonly stackId: string) { + super(`Managed stack ${stackId} was not found`); + this.name = "ManagedStackNotFoundError"; + } +} + +export class ManagedOperationInProgressError extends ManagedStackError { + readonly code = "MANAGED_OPERATION_IN_PROGRESS"; + + constructor( + readonly stackId: string, + readonly operation: ManagedOperationRecord, + ) { + super(`Managed stack ${stackId} already has an active ${operation.kind} operation`); + this.name = "ManagedOperationInProgressError"; + } +} + +export class ManagedOperationOwnershipError extends ManagedStackError { + readonly code = "MANAGED_OPERATION_OWNERSHIP_MISMATCH"; + + constructor(readonly stackId: string) { + super(`The active operation for managed stack ${stackId} is owned by another caller`); + this.name = "ManagedOperationOwnershipError"; + } +} + +export class ManagedPortReservationError extends ManagedStackError { + readonly code = "MANAGED_PORT_ALREADY_RESERVED"; + + constructor( + readonly port: number, + readonly ownerStackId: string, + ) { + super(`Port ${port} is already reserved by managed stack ${ownerStackId}`); + this.name = "ManagedPortReservationError"; + } +} + +export class ManagedStackInitializationError extends ManagedStackError { + readonly code = "MANAGED_STACK_INITIALIZATION_FAILED"; + + constructor( + readonly stackId: string, + override readonly cause: unknown, + ) { + super(`Managed stack ${stackId} could not be initialized`); + this.name = "ManagedStackInitializationError"; + } +} + +export class ManagedStackPublicationTimeoutError extends ManagedStackError { + readonly code = "MANAGED_STACK_PUBLICATION_TIMEOUT"; + + constructor(readonly stackId: string) { + super(`Timed out waiting for managed stack ${stackId} to be published`); + this.name = "ManagedStackPublicationTimeoutError"; + } +} + +export class ManagedAbandonedOperationError extends ManagedStackError { + readonly code = "MANAGED_OPERATION_REQUIRES_RECONCILIATION"; + + constructor(readonly stackId: string) { + super(`Managed stack ${stackId} has an abandoned operation that must be reconciled`); + this.name = "ManagedAbandonedOperationError"; + } +} diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts new file mode 100644 index 0000000000..5e2253a8b5 --- /dev/null +++ b/packages/stack/src/managed/paths.ts @@ -0,0 +1,51 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ManagedStackPaths } from "./model.ts"; + +export interface ManagedStateRootOptions { + readonly stateRoot?: string; + readonly env?: Readonly>; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; +} + +export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): string => { + if (options.stateRoot !== undefined) { + return options.stateRoot; + } + + const env = options.env ?? process.env; + const configuredHome = env["SUPABASE_HOME"]; + if (configuredHome !== undefined && configuredHome.length > 0) { + return join(configuredHome, "managed"); + } + + const platform = options.platform ?? process.platform; + const userHome = options.homeDir ?? homedir(); + if (platform === "darwin") { + return join(userHome, "Library", "Application Support", "supabase", "managed"); + } + if (platform === "win32") { + const localAppData = env["LOCALAPPDATA"]; + return join(localAppData ?? join(userHome, "AppData", "Local"), "Supabase", "managed"); + } + + const stateHome = env["XDG_STATE_HOME"]; + return join(stateHome ?? join(userHome, ".local", "state"), "supabase", "managed"); +}; + +export const managedRegistryPath = (stateRoot: string): string => + join(stateRoot, "registry-v1.sqlite3"); + +export const managedStackPaths = (stateRoot: string, stackId: string): ManagedStackPaths => { + const root = join(stateRoot, "stacks", stackId); + return { + root, + data: join(root, "data"), + logs: join(root, "logs"), + runtime: join(root, "runtime"), + }; +}; + +export const ordinaryWorkspaceIdentityPath = (workspacePath: string): string => + join(workspacePath, ".supabase", "identity.json"); diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts new file mode 100644 index 0000000000..dc3f7196db --- /dev/null +++ b/packages/stack/src/managed/repository.ts @@ -0,0 +1,489 @@ +import { + DuplicateManagedIdentityError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + type ManagedCheckoutLocation, + type ManagedOperationKind, + type ManagedOperationRecord, + type ManagedPortAssignment, + type ManagedRuntimeMetadata, + type ManagedStackConfiguration, + type ManagedStackLifecycle, + type ManagedStackPaths, + type ManagedStackRecord, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; + +export interface PrepareOrdinaryStackInput { + readonly identity: OrdinaryWorkspaceIdentity; + readonly canonicalPath: string; + readonly locationId: string; + readonly stackId: string; + readonly stackName: string; + readonly paths: ManagedStackPaths; + readonly operationToken: string; + readonly ownerPid?: number; + readonly now: string; + readonly configuration: ManagedStackConfiguration; +} + +export type PrepareOrdinaryStackResult = + | { + readonly outcome: "create"; + readonly stack: ManagedStackRecord; + readonly operation: ManagedOperationRecord; + } + | { + readonly outcome: "existing"; + readonly stack: ManagedStackRecord; + readonly operation?: ManagedOperationRecord; + }; + +export interface ClaimManagedOperationInput { + readonly token: string; + readonly stackId: string; + readonly kind: ManagedOperationKind; + readonly ownerPid?: number; + readonly now: string; +} + +export type ClaimManagedOperationResult = + | { readonly acquired: true; readonly operation: ManagedOperationRecord } + | { readonly acquired: false; readonly operation: ManagedOperationRecord }; + +export interface UpdateManagedStackInput extends ManagedStackConfiguration { + readonly stackId: string; + readonly operationToken: string; + readonly now: string; +} + +export interface ManagedStackRepository { + readonly kind: "in-memory" | "sqlite"; + prepareOrdinaryStack(input: PrepareOrdinaryStackInput): PrepareOrdinaryStackResult; + publishPendingStack(stackId: string, operationToken: string, now: string): ManagedStackRecord; + abortPendingStack(stackId: string, operationToken: string): void; + getStack(stackId: string): ManagedStackRecord | undefined; + getStackByIdentity( + checkoutId: string, + contextId: string, + stackName: string, + ): ManagedStackRecord | undefined; + listStacks(options?: { readonly includeTombstoned?: boolean }): ReadonlyArray; + claimOperation(input: ClaimManagedOperationInput): ClaimManagedOperationResult; + finishOperation( + stackId: string, + operationToken: string, + outcome: "completed" | "failed", + now: string, + error?: string, + ): void; + updateStack(input: UpdateManagedStackInput): ManagedStackRecord; + listActiveOperations(startedBefore?: string): ReadonlyArray; + reconcileOperation( + stackId: string, + operationToken: string, + lifecycle: ManagedStackLifecycle, + now: string, + ): ManagedStackRecord; + tombstoneStack(stackId: string, operationToken: string, now: string): ManagedStackRecord; + listCheckoutLocations(): ReadonlyArray; + pruneCheckoutLocations(locationIds: ReadonlyArray): number; + close(): void; +} + +interface InMemoryCheckout { + readonly id: string; + readonly projectId: string; +} + +interface InMemoryContext { + readonly id: string; + readonly checkoutId: string; +} + +const stackIdentityKey = (checkoutId: string, contextId: string, stackName: string): string => + `${checkoutId}\u0000${contextId}\u0000${stackName}`; + +const copy = (value: A): A => structuredClone(value); + +const applyConfiguration = ( + stack: ManagedStackRecord, + configuration: ManagedStackConfiguration, + now: string, +): ManagedStackRecord => ({ + ...stack, + lifecycle: configuration.lifecycle ?? stack.lifecycle, + runtimeRequest: configuration.runtimeRequest ?? stack.runtimeRequest, + runtime: configuration.runtime ?? stack.runtime, + ports: configuration.ports ?? stack.ports, + serviceVersions: configuration.serviceVersions ?? stack.serviceVersions, + runtimeMetadata: configuration.runtimeMetadata ?? stack.runtimeMetadata, + configFingerprint: configuration.configFingerprint ?? stack.configFingerprint, + credentialsReference: configuration.credentialsReference ?? stack.credentialsReference, + updatedAt: now, +}); + +const emptyRuntimeMetadata = (): ManagedRuntimeMetadata => ({ + processIds: {}, + containerIds: {}, +}); + +export const createInMemoryManagedStackRepository = (): ManagedStackRepository => { + const projects = new Set(); + const checkouts = new Map(); + const contexts = new Map(); + const locations = new Map(); + const stacks = new Map(); + const stackIdentities = new Map(); + const operations = new Map(); + const activeOperationByStack = new Map(); + const portOwners = new Map(); + + const atomic = (run: () => A): A => { + const snapshot = { + projects: structuredClone([...projects]), + checkouts: structuredClone([...checkouts]), + contexts: structuredClone([...contexts]), + locations: structuredClone([...locations]), + stacks: structuredClone([...stacks]), + stackIdentities: structuredClone([...stackIdentities]), + operations: structuredClone([...operations]), + activeOperationByStack: structuredClone([...activeOperationByStack]), + portOwners: structuredClone([...portOwners]), + }; + try { + return run(); + } catch (error: unknown) { + projects.clear(); + for (const project of snapshot.projects) projects.add(project); + checkouts.clear(); + for (const [key, value] of snapshot.checkouts) checkouts.set(key, value); + contexts.clear(); + for (const [key, value] of snapshot.contexts) contexts.set(key, value); + locations.clear(); + for (const [key, value] of snapshot.locations) locations.set(key, value); + stacks.clear(); + for (const [key, value] of snapshot.stacks) stacks.set(key, value); + stackIdentities.clear(); + for (const [key, value] of snapshot.stackIdentities) stackIdentities.set(key, value); + operations.clear(); + for (const [key, value] of snapshot.operations) operations.set(key, value); + activeOperationByStack.clear(); + for (const [key, value] of snapshot.activeOperationByStack) { + activeOperationByStack.set(key, value); + } + portOwners.clear(); + for (const [key, value] of snapshot.portOwners) portOwners.set(key, value); + throw error; + } + }; + + const requireStack = (stackId: string): ManagedStackRecord => { + const stack = stacks.get(stackId); + if (stack === undefined) { + throw new ManagedStackNotFoundError(stackId); + } + return stack; + }; + + const requireOwnedOperation = ( + stackId: string, + operationToken: string, + ): ManagedOperationRecord => { + const activeToken = activeOperationByStack.get(stackId); + const operation = operations.get(operationToken); + if ( + activeToken !== operationToken || + operation === undefined || + operation.stackId !== stackId || + operation.status !== "active" + ) { + throw new ManagedOperationOwnershipError(stackId); + } + return operation; + }; + + const reservePorts = ( + stackId: string, + current: ReadonlyArray, + next: ReadonlyArray, + ): void => { + for (const assignment of next) { + const owner = portOwners.get(assignment.port); + if (owner !== undefined && owner !== stackId) { + throw new ManagedPortReservationError(assignment.port, owner); + } + } + for (const assignment of current) { + if (portOwners.get(assignment.port) === stackId) { + portOwners.delete(assignment.port); + } + } + for (const assignment of next) { + portOwners.set(assignment.port, stackId); + } + }; + + const claimOperation = (input: ClaimManagedOperationInput): ClaimManagedOperationResult => { + requireStack(input.stackId); + const activeToken = activeOperationByStack.get(input.stackId); + if (activeToken !== undefined) { + const active = operations.get(activeToken); + if (active !== undefined) { + return { acquired: false, operation: copy(active) }; + } + } + + const operation: ManagedOperationRecord = { + token: input.token, + stackId: input.stackId, + kind: input.kind, + status: "active", + ownerPid: input.ownerPid, + startedAt: input.now, + }; + operations.set(operation.token, operation); + activeOperationByStack.set(operation.stackId, operation.token); + return { acquired: true, operation: copy(operation) }; + }; + + return { + kind: "in-memory", + prepareOrdinaryStack(input) { + return atomic(() => { + projects.add(input.identity.projectId); + const checkout = checkouts.get(input.identity.checkoutId); + if (checkout !== undefined && checkout.projectId !== input.identity.projectId) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + checkout.projectId, + input.identity.projectId, + ); + } + checkouts.set(input.identity.checkoutId, { + id: input.identity.checkoutId, + projectId: input.identity.projectId, + }); + + const context = contexts.get(input.identity.contextId); + if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + context.checkoutId, + input.identity.contextId, + ); + } + contexts.set(input.identity.contextId, { + id: input.identity.contextId, + checkoutId: input.identity.checkoutId, + }); + + const existingLocation = [...locations.values()].find( + (location) => location.checkoutId === input.identity.checkoutId, + ); + if ( + existingLocation !== undefined && + existingLocation.canonicalPath !== input.canonicalPath + ) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + existingLocation.canonicalPath, + input.canonicalPath, + ); + } + const pathOwner = [...locations.values()].find( + (location) => location.canonicalPath === input.canonicalPath, + ); + if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + pathOwner.canonicalPath, + input.canonicalPath, + ); + } + locations.set(existingLocation?.id ?? input.locationId, { + id: existingLocation?.id ?? input.locationId, + checkoutId: input.identity.checkoutId, + canonicalPath: input.canonicalPath, + lastSeenAt: input.now, + }); + + const identityKey = stackIdentityKey( + input.identity.checkoutId, + input.identity.contextId, + input.stackName, + ); + const existingStackId = stackIdentities.get(identityKey); + if (existingStackId !== undefined) { + const stack = requireStack(existingStackId); + const activeToken = activeOperationByStack.get(stack.id); + const operation = activeToken === undefined ? undefined : operations.get(activeToken); + return { + outcome: "existing", + stack: copy(stack), + operation: operation === undefined ? undefined : copy(operation), + }; + } + + const baseStack: ManagedStackRecord = { + id: input.stackId, + projectId: input.identity.projectId, + checkoutId: input.identity.checkoutId, + contextId: input.identity.contextId, + name: input.stackName, + status: "pending", + lifecycle: "stopped", + runtimeRequest: input.configuration.runtimeRequest ?? "auto", + runtime: input.configuration.runtime, + paths: input.paths, + ports: [], + serviceVersions: {}, + runtimeMetadata: emptyRuntimeMetadata(), + createdAt: input.now, + updatedAt: input.now, + }; + const stack = applyConfiguration(baseStack, input.configuration, input.now); + reservePorts(stack.id, [], stack.ports); + stacks.set(stack.id, stack); + stackIdentities.set(identityKey, stack.id); + const claimed = claimOperation({ + token: input.operationToken, + stackId: stack.id, + kind: "start", + ownerPid: input.ownerPid, + now: input.now, + }); + if (!claimed.acquired) { + throw new ManagedOperationOwnershipError(stack.id); + } + return { outcome: "create", stack: copy(stack), operation: claimed.operation }; + }); + }, + publishPendingStack(stackId, operationToken, now) { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { + ...current, + status: "active", + updatedAt: now, + }; + stacks.set(stackId, next); + const operation = operations.get(operationToken); + if (operation !== undefined) { + operations.set(operationToken, { + ...operation, + status: "completed", + finishedAt: now, + }); + } + activeOperationByStack.delete(stackId); + return copy(next); + }, + abortPendingStack(stackId, operationToken) { + requireOwnedOperation(stackId, operationToken); + const stack = requireStack(stackId); + if (stack.status !== "pending") { + throw new ManagedOperationOwnershipError(stackId); + } + reservePorts(stack.id, stack.ports, []); + stacks.delete(stackId); + stackIdentities.delete(stackIdentityKey(stack.checkoutId, stack.contextId, stack.name)); + operations.delete(operationToken); + activeOperationByStack.delete(stackId); + }, + getStack(stackId) { + const stack = stacks.get(stackId); + return stack === undefined ? undefined : copy(stack); + }, + getStackByIdentity(checkoutId, contextId, stackName) { + const stackId = stackIdentities.get(stackIdentityKey(checkoutId, contextId, stackName)); + if (stackId === undefined) { + return undefined; + } + const stack = stacks.get(stackId); + return stack === undefined ? undefined : copy(stack); + }, + listStacks(options) { + return [...stacks.values()] + .filter((stack) => options?.includeTombstoned === true || stack.status !== "tombstoned") + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) + .map(copy); + }, + claimOperation, + finishOperation(stackId, operationToken, outcome, now, error) { + const operation = requireOwnedOperation(stackId, operationToken); + operations.set(operationToken, { + ...operation, + status: outcome, + finishedAt: now, + error, + }); + activeOperationByStack.delete(stackId); + }, + updateStack(input) { + requireOwnedOperation(input.stackId, input.operationToken); + const current = requireStack(input.stackId); + const next = applyConfiguration(current, input, input.now); + reservePorts(current.id, current.ports, next.ports); + stacks.set(current.id, next); + return copy(next); + }, + listActiveOperations(startedBefore) { + return [...activeOperationByStack.values()] + .flatMap((token) => { + const operation = operations.get(token); + return operation === undefined ? [] : [operation]; + }) + .filter((operation) => startedBefore === undefined || operation.startedAt < startedBefore) + .sort((left, right) => left.startedAt.localeCompare(right.startedAt)) + .map(copy); + }, + reconcileOperation(stackId, operationToken, lifecycle, now) { + const operation = requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { ...current, lifecycle, updatedAt: now }; + stacks.set(stackId, next); + operations.set(operationToken, { + ...operation, + status: "failed", + finishedAt: now, + error: `Recovered after runtime reconciliation (${lifecycle})`, + }); + activeOperationByStack.delete(stackId); + return copy(next); + }, + tombstoneStack(stackId, operationToken, now) { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + reservePorts(current.id, current.ports, []); + const next: ManagedStackRecord = { + ...current, + status: "tombstoned", + lifecycle: "stopped", + ports: [], + runtimeMetadata: emptyRuntimeMetadata(), + updatedAt: now, + tombstonedAt: now, + }; + stacks.set(stackId, next); + stackIdentities.delete(stackIdentityKey(current.checkoutId, current.contextId, current.name)); + return copy(next); + }, + listCheckoutLocations() { + return [...locations.values()] + .sort((left, right) => left.canonicalPath.localeCompare(right.canonicalPath)) + .map(copy); + }, + pruneCheckoutLocations(locationIds) { + let removed = 0; + for (const id of new Set(locationIds)) { + if (locations.delete(id)) { + removed += 1; + } + } + return removed; + }, + close() {}, + }; +}; diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts new file mode 100644 index 0000000000..e0513f7a87 --- /dev/null +++ b/packages/stack/src/managed/service.ts @@ -0,0 +1,346 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, rm } from "node:fs/promises"; +import { + DEFAULT_MANAGED_STACK_NAME, + ManagedAbandonedOperationError, + ManagedOperationInProgressError, + ManagedStackInitializationError, + ManagedStackNotFoundError, + ManagedStackPublicationTimeoutError, + type ManagedCheckoutLocation, + type ManagedOperationKind, + type ManagedOperationRecord, + type ManagedStackConfiguration, + type ManagedStackLifecycle, + type ManagedStackRecord, + type ManagedStackSelection, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; +import { + canonicalizeOrdinaryWorkspacePath, + ensureOrdinaryWorkspaceIdentity, + readOrdinaryWorkspaceIdentity, +} from "./identity.ts"; +import { managedStackPaths } from "./paths.ts"; +import type { ManagedStackRepository } from "./repository.ts"; + +export interface ManagedStackServiceOptions { + readonly repository: ManagedStackRepository; + readonly stateRoot: string; + readonly idFactory?: () => string; + readonly clock?: () => Date; + readonly ownerPid?: number; + readonly publicationTimeoutMs?: number; + readonly publicationPollMs?: number; +} + +export interface ProvisionOrdinaryStackOptions { + readonly workspacePath: string; + readonly stackName?: string; + readonly configuration?: ManagedStackConfiguration; + readonly initialize?: (stack: ManagedStackRecord) => Promise; + readonly validate?: (stack: ManagedStackRecord) => Promise; +} + +export interface ProvisionOrdinaryStackResult { + readonly outcome: "create" | "reuse"; + readonly selection: ManagedStackSelection; + readonly stack: ManagedStackRecord; + readonly identityMarkerCreated: boolean; +} + +export interface InspectOrdinaryWorkspaceResult { + readonly registered: boolean; + readonly identity?: OrdinaryWorkspaceIdentity; + readonly stacks: ReadonlyArray; +} + +export interface DeleteManagedStackResult { + readonly outcome: "delete" | "no-op"; + readonly stack: ManagedStackRecord; +} + +export interface ReconcileAbandonedOperationsOptions { + readonly startedBefore?: string; + readonly inspectRuntime: ( + stack: ManagedStackRecord, + operation: ManagedOperationRecord, + ) => Promise<"running" | "stopped" | "unknown">; +} + +export interface ReconcileAbandonedOperationsResult { + readonly recovered: ReadonlyArray; + readonly retained: ReadonlyArray; +} + +export interface ManagedStackService { + readonly stateRoot: string; + readonly repository: ManagedStackRepository; + provisionOrdinaryStack( + options: ProvisionOrdinaryStackOptions, + ): Promise; + inspectOrdinaryWorkspace(workspacePath: string): Promise; + inspectStack(stackId: string): ManagedStackRecord | undefined; + listStacks(options?: { readonly includeTombstoned?: boolean }): ReadonlyArray; + updateStack( + stackId: string, + configuration: ManagedStackConfiguration, + ): Promise; + deleteStack( + stackId: string, + options?: { readonly stop?: (stack: ManagedStackRecord) => Promise }, + ): Promise; + reconcileAbandonedOperations( + options: ReconcileAbandonedOperationsOptions, + ): Promise; + pruneCheckoutLocations( + shouldPrune: (location: ManagedCheckoutLocation) => boolean | Promise, + ): Promise; + close(): void; +} + +const selectionForStack = (stack: ManagedStackRecord): ManagedStackSelection => ({ + projectId: stack.projectId, + checkoutId: stack.checkoutId, + contextId: stack.contextId, + stackId: stack.id, + stackName: stack.name, +}); + +const wait = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const stackNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +export const makeManagedStackService = ( + options: ManagedStackServiceOptions, +): ManagedStackService => { + const idFactory = options.idFactory ?? randomUUID; + const clock = options.clock ?? (() => new Date()); + const ownerPid = options.ownerPid ?? process.pid; + const publicationTimeoutMs = options.publicationTimeoutMs ?? 10_000; + const publicationPollMs = options.publicationPollMs ?? 10; + const now = (): string => clock().toISOString(); + + const requireOperation = ( + stackId: string, + kind: ManagedOperationKind, + ): ManagedOperationRecord => { + const claimed = options.repository.claimOperation({ + token: idFactory(), + stackId, + kind, + ownerPid, + now: now(), + }); + if (!claimed.acquired) { + throw new ManagedOperationInProgressError(stackId, claimed.operation); + } + return claimed.operation; + }; + + const awaitPublication = async (pending: ManagedStackRecord): Promise => { + const deadline = Date.now() + publicationTimeoutMs; + while (Date.now() <= deadline) { + const current = options.repository.getStack(pending.id); + if (current === undefined) { + throw new ManagedAbandonedOperationError(pending.id); + } + if (current.status === "active") { + return current; + } + if (current.status === "tombstoned") { + throw new ManagedStackNotFoundError(current.id); + } + await wait(publicationPollMs); + } + throw new ManagedStackPublicationTimeoutError(pending.id); + }; + + return { + stateRoot: options.stateRoot, + repository: options.repository, + async provisionOrdinaryStack(provisionOptions) { + const stackName = provisionOptions.stackName ?? DEFAULT_MANAGED_STACK_NAME; + if (!stackNamePattern.test(stackName)) { + throw new Error(`Invalid managed stack name: ${stackName}`); + } + const canonicalPath = await canonicalizeOrdinaryWorkspacePath(provisionOptions.workspacePath); + const marker = await ensureOrdinaryWorkspaceIdentity(canonicalPath, idFactory); + const stackId = idFactory(); + const prepared = options.repository.prepareOrdinaryStack({ + identity: marker.identity, + canonicalPath, + locationId: idFactory(), + stackId, + stackName, + paths: managedStackPaths(options.stateRoot, stackId), + operationToken: idFactory(), + ownerPid, + now: now(), + configuration: provisionOptions.configuration ?? {}, + }); + + if (prepared.outcome === "existing") { + if (prepared.stack.status === "active") { + return { + outcome: "reuse", + selection: selectionForStack(prepared.stack), + stack: prepared.stack, + identityMarkerCreated: marker.created, + }; + } + if (prepared.operation === undefined) { + throw new ManagedAbandonedOperationError(prepared.stack.id); + } + const published = await awaitPublication(prepared.stack); + return { + outcome: "reuse", + selection: selectionForStack(published), + stack: published, + identityMarkerCreated: marker.created, + }; + } + + try { + await mkdir(prepared.stack.paths.data, { recursive: true }); + await mkdir(prepared.stack.paths.logs, { recursive: true }); + await mkdir(prepared.stack.paths.runtime, { recursive: true }); + await provisionOptions.initialize?.(prepared.stack); + await provisionOptions.validate?.(prepared.stack); + const published = options.repository.publishPendingStack( + prepared.stack.id, + prepared.operation.token, + now(), + ); + return { + outcome: "create", + selection: selectionForStack(published), + stack: published, + identityMarkerCreated: marker.created, + }; + } catch (cause: unknown) { + await rm(prepared.stack.paths.root, { force: true, recursive: true }).catch( + () => undefined, + ); + options.repository.abortPendingStack(prepared.stack.id, prepared.operation.token); + throw new ManagedStackInitializationError(prepared.stack.id, cause); + } + }, + async inspectOrdinaryWorkspace(workspacePath) { + const canonicalPath = await canonicalizeOrdinaryWorkspacePath(workspacePath); + const identity = await readOrdinaryWorkspaceIdentity(canonicalPath); + if (identity === undefined) { + return { registered: false, stacks: [] }; + } + const stacks = options.repository + .listStacks() + .filter( + (stack) => + stack.projectId === identity.projectId && stack.checkoutId === identity.checkoutId, + ); + return { registered: stacks.length > 0, identity, stacks }; + }, + inspectStack(stackId) { + return options.repository.getStack(stackId); + }, + listStacks(listOptions) { + return options.repository.listStacks(listOptions); + }, + async updateStack(stackId, configuration) { + const operation = requireOperation(stackId, "update"); + try { + const stack = options.repository.updateStack({ + stackId, + operationToken: operation.token, + now: now(), + ...configuration, + }); + options.repository.finishOperation(stackId, operation.token, "completed", now()); + return stack; + } catch (error: unknown) { + options.repository.finishOperation( + stackId, + operation.token, + "failed", + now(), + String(error), + ); + throw error; + } + }, + async deleteStack(stackId, deleteOptions) { + const existing = options.repository.getStack(stackId); + if (existing === undefined) { + throw new ManagedStackNotFoundError(stackId); + } + if (existing.status === "tombstoned") { + return { outcome: "no-op", stack: existing }; + } + const operation = requireOperation(stackId, "delete"); + try { + if (existing.lifecycle !== "stopped") { + if (deleteOptions?.stop === undefined) { + throw new Error(`Managed stack ${stackId} must be safely stopped before deletion`); + } + await deleteOptions.stop(existing); + options.repository.updateStack({ + stackId, + operationToken: operation.token, + now: now(), + lifecycle: "stopped", + runtimeMetadata: { processIds: {}, containerIds: {} }, + }); + } + const tombstoned = options.repository.tombstoneStack(stackId, operation.token, now()); + await rm(tombstoned.paths.root, { force: true, recursive: true }); + options.repository.finishOperation(stackId, operation.token, "completed", now()); + return { outcome: "delete", stack: tombstoned }; + } catch (error: unknown) { + options.repository.finishOperation( + stackId, + operation.token, + "failed", + now(), + String(error), + ); + throw error; + } + }, + async reconcileAbandonedOperations(reconcileOptions) { + const recovered: Array = []; + const retained: Array = []; + for (const operation of options.repository.listActiveOperations( + reconcileOptions.startedBefore, + )) { + const stack = options.repository.getStack(operation.stackId); + if (stack === undefined) { + retained.push(operation); + continue; + } + const actual = await reconcileOptions.inspectRuntime(stack, operation); + if (actual === "unknown") { + retained.push(operation); + continue; + } + const lifecycle: ManagedStackLifecycle = actual === "running" ? "running" : "stopped"; + recovered.push( + options.repository.reconcileOperation(stack.id, operation.token, lifecycle, now()), + ); + } + return { recovered, retained }; + }, + async pruneCheckoutLocations(shouldPrune) { + const stale: Array = []; + for (const location of options.repository.listCheckoutLocations()) { + if (await shouldPrune(location)) { + stale.push(location.id); + } + } + return options.repository.pruneCheckoutLocations(stale); + }, + close() { + options.repository.close(); + }, + }; +}; diff --git a/packages/stack/src/managed/sqlite-bun.ts b/packages/stack/src/managed/sqlite-bun.ts new file mode 100644 index 0000000000..7ce9019243 --- /dev/null +++ b/packages/stack/src/managed/sqlite-bun.ts @@ -0,0 +1,39 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; + +export const openBunSqliteManagedStackRepository = (path: string) => { + if (path !== ":memory:") { + mkdirSync(dirname(path), { recursive: true }); + } + const database = new Database(path, { create: true }); + const adapter: ManagedSqliteDatabase = { + exec(sql) { + database.exec(sql); + }, + prepare(sql) { + const statement = database.query(sql); + return { + run(parameters = []) { + statement.run(...parameters); + }, + get(parameters = []) { + return statement.get(...parameters) ?? undefined; + }, + all(parameters = []) { + return statement.all(...parameters); + }, + }; + }, + close() { + database.close(); + }, + }; + try { + return createSqliteManagedStackRepository(adapter); + } catch (error: unknown) { + database.close(); + throw error; + } +}; diff --git a/packages/stack/src/managed/sqlite-node.ts b/packages/stack/src/managed/sqlite-node.ts new file mode 100644 index 0000000000..a2e848609e --- /dev/null +++ b/packages/stack/src/managed/sqlite-node.ts @@ -0,0 +1,39 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; + +export const openNodeSqliteManagedStackRepository = (path: string) => { + if (path !== ":memory:") { + mkdirSync(dirname(path), { recursive: true }); + } + const database = new DatabaseSync(path); + const adapter: ManagedSqliteDatabase = { + exec(sql) { + database.exec(sql); + }, + prepare(sql) { + const statement = database.prepare(sql); + return { + run(parameters = []) { + statement.run(...parameters); + }, + get(parameters = []) { + return statement.get(...parameters); + }, + all(parameters = []) { + return statement.all(...parameters); + }, + }; + }, + close() { + database.close(); + }, + }; + try { + return createSqliteManagedStackRepository(adapter); + } catch (error: unknown) { + database.close(); + throw error; + } +}; diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts new file mode 100644 index 0000000000..d922cbced3 --- /dev/null +++ b/packages/stack/src/managed/sqlite.ts @@ -0,0 +1,750 @@ +import { Schema } from "effect"; +import { + DuplicateManagedIdentityError, + MANAGED_REGISTRY_SCHEMA_VERSION, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + UnsupportedManagedRegistryVersionError, + type ManagedCheckoutLocation, + type ManagedOperationKind, + type ManagedOperationRecord, + type ManagedOperationStatus, + type ManagedPortAssignment, + type ManagedPortIntent, + type ManagedRuntime, + type ManagedRuntimeMetadata, + type ManagedRuntimeRequest, + type ManagedStackLifecycle, + type ManagedStackPaths, + type ManagedStackRecord, + type ManagedStackStatus, +} from "./model.ts"; +import type { + ClaimManagedOperationInput, + ClaimManagedOperationResult, + ManagedStackRepository, + PrepareOrdinaryStackInput, + PrepareOrdinaryStackResult, + UpdateManagedStackInput, +} from "./repository.ts"; + +type SqliteValue = null | number | string; + +interface ManagedSqliteStatement { + run(parameters?: ReadonlyArray): void; + get(parameters?: ReadonlyArray): unknown; + all(parameters?: ReadonlyArray): ReadonlyArray; +} + +export interface ManagedSqliteDatabase { + exec(sql: string): void; + prepare(sql: string): ManagedSqliteStatement; + close(): void; +} + +const stringRecordSchema = Schema.Record(Schema.String, Schema.String); +const numberRecordSchema = Schema.Record(Schema.String, Schema.Number); +const runtimeMetadataSchema = Schema.Struct({ + pid: Schema.optional(Schema.Number), + socketPath: Schema.optional(Schema.String), + processIds: numberRecordSchema, + containerIds: stringRecordSchema, +}); +const decodeStringRecord = Schema.decodeUnknownSync(stringRecordSchema); +const decodeRuntimeMetadata = Schema.decodeUnknownSync(runtimeMetadataSchema); + +const getField = (row: unknown, field: string): unknown => { + if (typeof row !== "object" || row === null) { + throw new Error(`SQLite row is missing ${field}`); + } + return Reflect.get(row, field); +}; + +const getString = (row: unknown, field: string): string => { + const value = getField(row, field); + if (typeof value !== "string") { + throw new Error(`SQLite column ${field} is not a string`); + } + return value; +}; + +const getOptionalString = (row: unknown, field: string): string | undefined => { + const value = getField(row, field); + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`SQLite column ${field} is not a nullable string`); + } + return value; +}; + +const getNumber = (row: unknown, field: string): number => { + const value = getField(row, field); + if (typeof value !== "number") { + throw new Error(`SQLite column ${field} is not a number`); + } + return value; +}; + +const getOptionalNumber = (row: unknown, field: string): number | undefined => { + const value = getField(row, field); + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== "number") { + throw new Error(`SQLite column ${field} is not a nullable number`); + } + return value; +}; + +const parseJson = (value: string): unknown => JSON.parse(value); + +const managedRuntimeRequest = (value: string): ManagedRuntimeRequest => { + if (value === "auto" || value === "docker" || value === "native") { + return value; + } + throw new Error(`Unknown managed runtime request ${value}`); +}; + +const managedRuntime = (value: string | undefined): ManagedRuntime | undefined => { + if (value === undefined || value === "docker" || value === "native") { + return value; + } + throw new Error(`Unknown managed runtime ${value}`); +}; + +const managedStackStatus = (value: string): ManagedStackStatus => { + if (value === "active" || value === "pending" || value === "tombstoned") { + return value; + } + throw new Error(`Unknown managed stack status ${value}`); +}; + +const managedStackLifecycle = (value: string): ManagedStackLifecycle => { + if ( + value === "failed" || + value === "running" || + value === "starting" || + value === "stopped" || + value === "stopping" + ) { + return value; + } + throw new Error(`Unknown managed stack lifecycle ${value}`); +}; + +const managedOperationKind = (value: string): ManagedOperationKind => { + if (value === "delete" || value === "start" || value === "stop" || value === "update") { + return value; + } + throw new Error(`Unknown managed operation kind ${value}`); +}; + +const managedOperationStatus = (value: string): ManagedOperationStatus => { + if (value === "active" || value === "completed" || value === "failed") { + return value; + } + throw new Error(`Unknown managed operation status ${value}`); +}; + +const managedPortIntent = (value: string): ManagedPortIntent => { + if (value === "automatic" || value === "exact") { + return value; + } + throw new Error(`Unknown managed port intent ${value}`); +}; + +const initializeSchema = (database: ManagedSqliteDatabase): void => { + database.exec("PRAGMA foreign_keys = ON"); + database.exec("PRAGMA journal_mode = WAL"); + database.exec("PRAGMA busy_timeout = 5000"); + const versionRow = database.prepare("PRAGMA user_version").get(); + const version = getNumber(versionRow, "user_version"); + if (version > MANAGED_REGISTRY_SCHEMA_VERSION) { + throw new UnsupportedManagedRegistryVersionError(version, MANAGED_REGISTRY_SCHEMA_VERSION); + } + if (version === MANAGED_REGISTRY_SCHEMA_VERSION) { + return; + } + + database.exec("BEGIN IMMEDIATE"); + try { + database.exec(` + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL + ); + + CREATE TABLE checkouts ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + created_at TEXT NOT NULL + ); + + CREATE TABLE checkout_locations ( + id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL REFERENCES checkouts(id), + canonical_path TEXT NOT NULL UNIQUE, + last_seen_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX one_ordinary_location_per_checkout + ON checkout_locations(checkout_id); + + CREATE TABLE contexts ( + id TEXT PRIMARY KEY, + checkout_id TEXT NOT NULL REFERENCES checkouts(id), + kind TEXT NOT NULL CHECK (kind IN ('workspace', 'branch', 'detached')), + locator TEXT, + status TEXT NOT NULL CHECK (status IN ('active', 'orphaned')), + created_at TEXT NOT NULL + ); + + CREATE TABLE stacks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + checkout_id TEXT NOT NULL REFERENCES checkouts(id), + context_id TEXT NOT NULL REFERENCES contexts(id), + name TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'tombstoned')), + lifecycle TEXT NOT NULL CHECK (lifecycle IN ('stopped', 'starting', 'running', 'stopping', 'failed')), + runtime_request TEXT NOT NULL CHECK (runtime_request IN ('auto', 'docker', 'native')), + runtime TEXT CHECK (runtime IN ('docker', 'native')), + root_path TEXT NOT NULL, + data_path TEXT NOT NULL, + logs_path TEXT NOT NULL, + runtime_path TEXT NOT NULL, + config_fingerprint TEXT, + credentials_reference TEXT, + service_versions_json TEXT NOT NULL, + runtime_metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + tombstoned_at TEXT + ); + CREATE UNIQUE INDEX one_live_stack_per_identity + ON stacks(checkout_id, context_id, name) + WHERE status != 'tombstoned'; + + CREATE TABLE ports ( + stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE, + key TEXT NOT NULL, + port INTEGER NOT NULL UNIQUE, + intent TEXT NOT NULL CHECK (intent IN ('automatic', 'exact')), + PRIMARY KEY (stack_id, key) + ); + + CREATE TABLE operations ( + token TEXT PRIMARY KEY, + stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('start', 'stop', 'delete', 'update')), + status TEXT NOT NULL CHECK (status IN ('active', 'completed', 'failed')), + owner_pid INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT, + error TEXT + ); + CREATE UNIQUE INDEX one_active_operation_per_stack + ON operations(stack_id) + WHERE status = 'active'; + + PRAGMA user_version = ${MANAGED_REGISTRY_SCHEMA_VERSION}; + `); + database.exec("COMMIT"); + } catch (error: unknown) { + database.exec("ROLLBACK"); + throw error; + } +}; + +const transaction = (database: ManagedSqliteDatabase, run: () => A): A => { + database.exec("BEGIN IMMEDIATE"); + try { + const result = run(); + database.exec("COMMIT"); + return result; + } catch (error: unknown) { + database.exec("ROLLBACK"); + throw error; + } +}; + +const queryPorts = ( + database: ManagedSqliteDatabase, + stackId: string, +): ReadonlyArray => + database + .prepare("SELECT key, port, intent FROM ports WHERE stack_id = ? ORDER BY key") + .all([stackId]) + .map((row) => ({ + key: getString(row, "key"), + port: getNumber(row, "port"), + intent: managedPortIntent(getString(row, "intent")), + })); + +const decodeStack = (database: ManagedSqliteDatabase, row: unknown): ManagedStackRecord => { + const id = getString(row, "id"); + const paths: ManagedStackPaths = { + root: getString(row, "root_path"), + data: getString(row, "data_path"), + logs: getString(row, "logs_path"), + runtime: getString(row, "runtime_path"), + }; + return { + id, + projectId: getString(row, "project_id"), + checkoutId: getString(row, "checkout_id"), + contextId: getString(row, "context_id"), + name: getString(row, "name"), + status: managedStackStatus(getString(row, "status")), + lifecycle: managedStackLifecycle(getString(row, "lifecycle")), + runtimeRequest: managedRuntimeRequest(getString(row, "runtime_request")), + runtime: managedRuntime(getOptionalString(row, "runtime")), + paths, + ports: queryPorts(database, id), + serviceVersions: decodeStringRecord(parseJson(getString(row, "service_versions_json"))), + runtimeMetadata: decodeRuntimeMetadata(parseJson(getString(row, "runtime_metadata_json"))), + configFingerprint: getOptionalString(row, "config_fingerprint"), + credentialsReference: getOptionalString(row, "credentials_reference"), + createdAt: getString(row, "created_at"), + updatedAt: getString(row, "updated_at"), + tombstonedAt: getOptionalString(row, "tombstoned_at"), + }; +}; + +const decodeOperation = (row: unknown): ManagedOperationRecord => ({ + token: getString(row, "token"), + stackId: getString(row, "stack_id"), + kind: managedOperationKind(getString(row, "kind")), + status: managedOperationStatus(getString(row, "status")), + ownerPid: getOptionalNumber(row, "owner_pid"), + startedAt: getString(row, "started_at"), + finishedAt: getOptionalString(row, "finished_at"), + error: getOptionalString(row, "error"), +}); + +const getStack = ( + database: ManagedSqliteDatabase, + stackId: string, +): ManagedStackRecord | undefined => { + const row = database.prepare("SELECT * FROM stacks WHERE id = ?").get([stackId]); + return row === undefined ? undefined : decodeStack(database, row); +}; + +const requireStack = (database: ManagedSqliteDatabase, stackId: string): ManagedStackRecord => { + const stack = getStack(database, stackId); + if (stack === undefined) { + throw new ManagedStackNotFoundError(stackId); + } + return stack; +}; + +const getActiveOperation = ( + database: ManagedSqliteDatabase, + stackId: string, +): ManagedOperationRecord | undefined => { + const row = database + .prepare("SELECT * FROM operations WHERE stack_id = ? AND status = 'active'") + .get([stackId]); + return row === undefined ? undefined : decodeOperation(row); +}; + +const requireOwnedOperation = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, +): ManagedOperationRecord => { + const operation = getActiveOperation(database, stackId); + if (operation === undefined || operation.token !== operationToken) { + throw new ManagedOperationOwnershipError(stackId); + } + return operation; +}; + +const replacePorts = ( + database: ManagedSqliteDatabase, + stackId: string, + ports: ReadonlyArray, +): void => { + for (const assignment of ports) { + const owner = database + .prepare("SELECT stack_id FROM ports WHERE port = ? AND stack_id != ?") + .get([assignment.port, stackId]); + if (owner !== undefined) { + throw new ManagedPortReservationError(assignment.port, getString(owner, "stack_id")); + } + } + database.prepare("DELETE FROM ports WHERE stack_id = ?").run([stackId]); + const insert = database.prepare( + "INSERT INTO ports (stack_id, key, port, intent) VALUES (?, ?, ?, ?)", + ); + for (const assignment of ports) { + insert.run([stackId, assignment.key, assignment.port, assignment.intent]); + } +}; + +const claimOperation = ( + database: ManagedSqliteDatabase, + input: ClaimManagedOperationInput, +): ClaimManagedOperationResult => + transaction(database, () => { + requireStack(database, input.stackId); + const active = getActiveOperation(database, input.stackId); + if (active !== undefined) { + return { acquired: false, operation: active }; + } + database + .prepare( + `INSERT INTO operations + (token, stack_id, kind, status, owner_pid, started_at) + VALUES (?, ?, ?, 'active', ?, ?)`, + ) + .run([input.token, input.stackId, input.kind, input.ownerPid ?? null, input.now]); + const operation = getActiveOperation(database, input.stackId); + if (operation === undefined) { + throw new ManagedOperationOwnershipError(input.stackId); + } + return { acquired: true, operation }; + }); + +const insertConfiguration = ( + database: ManagedSqliteDatabase, + input: PrepareOrdinaryStackInput, +): void => { + const runtimeMetadata: ManagedRuntimeMetadata = input.configuration.runtimeMetadata ?? { + processIds: {}, + containerIds: {}, + }; + database + .prepare( + `INSERT INTO stacks ( + id, project_id, checkout_id, context_id, name, status, lifecycle, + runtime_request, runtime, root_path, data_path, logs_path, runtime_path, + config_fingerprint, credentials_reference, service_versions_json, + runtime_metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run([ + input.stackId, + input.identity.projectId, + input.identity.checkoutId, + input.identity.contextId, + input.stackName, + input.configuration.lifecycle ?? "stopped", + input.configuration.runtimeRequest ?? "auto", + input.configuration.runtime ?? null, + input.paths.root, + input.paths.data, + input.paths.logs, + input.paths.runtime, + input.configuration.configFingerprint ?? null, + input.configuration.credentialsReference ?? null, + JSON.stringify(input.configuration.serviceVersions ?? {}), + JSON.stringify(runtimeMetadata), + input.now, + input.now, + ]); + replacePorts(database, input.stackId, input.configuration.ports ?? []); +}; + +export const createSqliteManagedStackRepository = ( + database: ManagedSqliteDatabase, +): ManagedStackRepository => { + initializeSchema(database); + + return { + kind: "sqlite", + prepareOrdinaryStack(input): PrepareOrdinaryStackResult { + return transaction(database, () => { + database + .prepare("INSERT OR IGNORE INTO projects (id, created_at) VALUES (?, ?)") + .run([input.identity.projectId, input.now]); + + const checkoutRow = database + .prepare("SELECT project_id FROM checkouts WHERE id = ?") + .get([input.identity.checkoutId]); + if ( + checkoutRow !== undefined && + getString(checkoutRow, "project_id") !== input.identity.projectId + ) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + getString(checkoutRow, "project_id"), + input.identity.projectId, + ); + } + database + .prepare("INSERT OR IGNORE INTO checkouts (id, project_id, created_at) VALUES (?, ?, ?)") + .run([input.identity.checkoutId, input.identity.projectId, input.now]); + + const contextRow = database + .prepare("SELECT checkout_id FROM contexts WHERE id = ?") + .get([input.identity.contextId]); + if ( + contextRow !== undefined && + getString(contextRow, "checkout_id") !== input.identity.checkoutId + ) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + getString(contextRow, "checkout_id"), + input.identity.contextId, + ); + } + database + .prepare( + `INSERT OR IGNORE INTO contexts + (id, checkout_id, kind, locator, status, created_at) + VALUES (?, ?, 'workspace', NULL, 'active', ?)`, + ) + .run([input.identity.contextId, input.identity.checkoutId, input.now]); + + const checkoutLocation = database + .prepare("SELECT * FROM checkout_locations WHERE checkout_id = ?") + .get([input.identity.checkoutId]); + if ( + checkoutLocation !== undefined && + getString(checkoutLocation, "canonical_path") !== input.canonicalPath + ) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + getString(checkoutLocation, "canonical_path"), + input.canonicalPath, + ); + } + const pathLocation = database + .prepare("SELECT * FROM checkout_locations WHERE canonical_path = ?") + .get([input.canonicalPath]); + if ( + pathLocation !== undefined && + getString(pathLocation, "checkout_id") !== input.identity.checkoutId + ) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + getString(pathLocation, "canonical_path"), + input.canonicalPath, + ); + } + if (checkoutLocation === undefined) { + database + .prepare( + `INSERT INTO checkout_locations + (id, checkout_id, canonical_path, last_seen_at) + VALUES (?, ?, ?, ?)`, + ) + .run([input.locationId, input.identity.checkoutId, input.canonicalPath, input.now]); + } else { + database + .prepare("UPDATE checkout_locations SET last_seen_at = ? WHERE id = ?") + .run([input.now, getString(checkoutLocation, "id")]); + } + + const existingRow = database + .prepare( + `SELECT * FROM stacks + WHERE checkout_id = ? AND context_id = ? AND name = ? AND status != 'tombstoned'`, + ) + .get([input.identity.checkoutId, input.identity.contextId, input.stackName]); + if (existingRow !== undefined) { + const stack = decodeStack(database, existingRow); + const operation = getActiveOperation(database, stack.id); + return { outcome: "existing", stack, operation }; + } + + insertConfiguration(database, input); + database + .prepare( + `INSERT INTO operations + (token, stack_id, kind, status, owner_pid, started_at) + VALUES (?, ?, 'start', 'active', ?, ?)`, + ) + .run([input.operationToken, input.stackId, input.ownerPid ?? null, input.now]); + const stack = requireStack(database, input.stackId); + const operation = getActiveOperation(database, input.stackId); + if (operation === undefined) { + throw new ManagedOperationOwnershipError(input.stackId); + } + return { outcome: "create", stack, operation }; + }); + }, + publishPendingStack(stackId, operationToken, now) { + return transaction(database, () => { + requireOwnedOperation(database, stackId, operationToken); + database + .prepare("UPDATE stacks SET status = 'active', updated_at = ? WHERE id = ?") + .run([now, stackId]); + database + .prepare( + `UPDATE operations + SET status = 'completed', finished_at = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([now, operationToken, stackId]); + return requireStack(database, stackId); + }); + }, + abortPendingStack(stackId, operationToken) { + transaction(database, () => { + requireOwnedOperation(database, stackId, operationToken); + const stack = requireStack(database, stackId); + if (stack.status !== "pending") { + throw new ManagedOperationOwnershipError(stackId); + } + database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); + }); + }, + getStack(stackId) { + return getStack(database, stackId); + }, + getStackByIdentity(checkoutId, contextId, stackName) { + const row = database + .prepare( + `SELECT * FROM stacks + WHERE checkout_id = ? AND context_id = ? AND name = ? AND status != 'tombstoned'`, + ) + .get([checkoutId, contextId, stackName]); + return row === undefined ? undefined : decodeStack(database, row); + }, + listStacks(options) { + const rows = + options?.includeTombstoned === true + ? database.prepare("SELECT * FROM stacks ORDER BY created_at, id").all() + : database + .prepare("SELECT * FROM stacks WHERE status != 'tombstoned' ORDER BY created_at, id") + .all(); + return rows.map((row) => decodeStack(database, row)); + }, + claimOperation(input) { + return claimOperation(database, input); + }, + finishOperation(stackId, operationToken, outcome, now, error) { + transaction(database, () => { + requireOwnedOperation(database, stackId, operationToken); + database + .prepare( + `UPDATE operations + SET status = ?, finished_at = ?, error = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([outcome, now, error ?? null, operationToken, stackId]); + }); + }, + updateStack(input: UpdateManagedStackInput) { + return transaction(database, () => { + requireOwnedOperation(database, input.stackId, input.operationToken); + const current = requireStack(database, input.stackId); + const runtimeRequest = input.runtimeRequest ?? current.runtimeRequest; + const runtime = input.runtime ?? current.runtime; + const lifecycle = input.lifecycle ?? current.lifecycle; + const serviceVersions = input.serviceVersions ?? current.serviceVersions; + const runtimeMetadata = input.runtimeMetadata ?? current.runtimeMetadata; + const configFingerprint = input.configFingerprint ?? current.configFingerprint; + const credentialsReference = input.credentialsReference ?? current.credentialsReference; + database + .prepare( + `UPDATE stacks SET + lifecycle = ?, runtime_request = ?, runtime = ?, + service_versions_json = ?, runtime_metadata_json = ?, + config_fingerprint = ?, credentials_reference = ?, updated_at = ? + WHERE id = ?`, + ) + .run([ + lifecycle, + runtimeRequest, + runtime ?? null, + JSON.stringify(serviceVersions), + JSON.stringify(runtimeMetadata), + configFingerprint ?? null, + credentialsReference ?? null, + input.now, + input.stackId, + ]); + replacePorts(database, input.stackId, input.ports ?? current.ports); + return requireStack(database, input.stackId); + }); + }, + listActiveOperations(startedBefore) { + const rows = + startedBefore === undefined + ? database + .prepare("SELECT * FROM operations WHERE status = 'active' ORDER BY started_at") + .all() + : database + .prepare( + `SELECT * FROM operations + WHERE status = 'active' AND started_at < ? ORDER BY started_at`, + ) + .all([startedBefore]); + return rows.map(decodeOperation); + }, + reconcileOperation(stackId, operationToken, lifecycle, now) { + return transaction(database, () => { + requireOwnedOperation(database, stackId, operationToken); + database + .prepare("UPDATE stacks SET lifecycle = ?, updated_at = ? WHERE id = ?") + .run([lifecycle, now, stackId]); + database + .prepare( + `UPDATE operations SET + status = 'failed', finished_at = ?, error = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([ + now, + `Recovered after runtime reconciliation (${lifecycle})`, + operationToken, + stackId, + ]); + return requireStack(database, stackId); + }); + }, + tombstoneStack(stackId, operationToken, now) { + return transaction(database, () => { + requireOwnedOperation(database, stackId, operationToken); + requireStack(database, stackId); + database.prepare("DELETE FROM ports WHERE stack_id = ?").run([stackId]); + database + .prepare( + `UPDATE stacks SET + status = 'tombstoned', lifecycle = 'stopped', + runtime_metadata_json = ?, updated_at = ?, tombstoned_at = ? + WHERE id = ?`, + ) + .run([JSON.stringify({ processIds: {}, containerIds: {} }), now, now, stackId]); + return requireStack(database, stackId); + }); + }, + listCheckoutLocations() { + return database + .prepare("SELECT * FROM checkout_locations ORDER BY canonical_path") + .all() + .map( + (row): ManagedCheckoutLocation => ({ + id: getString(row, "id"), + checkoutId: getString(row, "checkout_id"), + canonicalPath: getString(row, "canonical_path"), + lastSeenAt: getString(row, "last_seen_at"), + }), + ); + }, + pruneCheckoutLocations(locationIds) { + return transaction(database, () => { + let removed = 0; + const statement = database.prepare("DELETE FROM checkout_locations WHERE id = ?"); + for (const id of new Set(locationIds)) { + const existing = database + .prepare("SELECT id FROM checkout_locations WHERE id = ?") + .get([id]); + if (existing !== undefined) { + statement.run([id]); + removed += 1; + } + } + return removed; + }); + }, + close() { + database.close(); + }, + }; +}; diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index f2316af105..3a4ba58979 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -18,4 +18,5 @@ export { managedStackContractFixtures, } from "./managed-stack-contract.ts"; export { validateManagedStackContractFixtures } from "./managed-stack-contract-validation.ts"; +export { createInMemoryManagedStackRepository } from "./managed/repository.ts"; export { UnixHttpClient } from "./UnixHttpClient.ts"; From bff450a5d8324e79eb7e97d1e315c99ea3397b83 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 13:18:45 +0200 Subject: [PATCH 02/18] fix(stack): harden managed persistence --- packages/stack/README.md | 4 +- packages/stack/docs/architecture.md | 27 +- packages/stack/package.json | 3 +- packages/stack/src/managed-bun.ts | 2 + packages/stack/src/managed-node.ts | 2 + packages/stack/src/managed-paths.unit.test.ts | 30 +- .../src/managed-service.integration.test.ts | 620 +++++++++++++++++- packages/stack/src/managed.ts | 1 + packages/stack/src/managed/identity.ts | 20 +- packages/stack/src/managed/ids.ts | 13 + packages/stack/src/managed/model.ts | 27 +- packages/stack/src/managed/paths.ts | 30 +- packages/stack/src/managed/repository.ts | 140 +++- packages/stack/src/managed/service.ts | 196 ++++-- packages/stack/src/managed/sqlite-node.ts | 2 +- packages/stack/src/managed/sqlite.ts | 172 +++-- 16 files changed, 1126 insertions(+), 163 deletions(-) create mode 100644 packages/stack/src/managed/ids.ts diff --git a/packages/stack/README.md b/packages/stack/README.md index ab5f40dd43..718979e656 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -60,7 +60,9 @@ managed.close(); Managed state uses opaque project, checkout, context, and stack UUIDs. A non-Git workspace stores only its three identity UUIDs in `.supabase/identity.json`; mutable state, logs, runtime metadata, ports, and lifecycle ownership live under the user-level managed state root. Callers can inject an -in-memory repository or an isolated state root for tests. +in-memory repository or an isolated state root for tests. Stopped stacks keep sticky port +assignments without holding a host-wide lease; exact configuration takes precedence when a stopped +stack is updated, and active stacks reject port drift until stopped. ### With explicit config diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index d24eef2846..ae1ebe631e 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -276,6 +276,12 @@ See [detach mode](./detach-mode.md) for paths, process startup, and compiled exe ## Managed identity and state +Here, **managed state** means the centralized registry API exposed from +`@supabase/stack/managed`. It is distinct from the older `ManagedStack` daemon-discovery record in +`managed-stack.ts`, which remains part of the legacy Effect daemon surface. The registry API uses +Promises because its consumers perform short filesystem and SQLite coordination around the +Promise-oriented `createStack()` boundary; the runtime lifecycle beneath it remains Effect-based. + The managed surface owns a versioned SQLite registry with separate records for projects, checkouts, checkout locations, development contexts, stacks, port reservations, and operations. The public repository contract contains no SQLite types, so the same service runs with the @@ -310,10 +316,23 @@ stack UUID: Stack publication and operation claims are transactional. A new stack remains `pending` while its directories and caller-supplied initialization are validated, then becomes `active` atomically. -Concurrent callers resolve the published record rather than creating aliases. Recovery retains an -abandoned claim until a runtime inspector reports the actual running or stopped state. Explicit -deletion safely stops, tombstones, and removes only the selected stack root; prune removes checkout -location metadata only. +Concurrent callers resolve the published record rather than creating aliases. Recovery first +retains claims whose owner process is still alive. Once an owner is gone, runtime inspection either +publishes a running pending stack or aborts a stopped pending stack so the same identity can retry. +Ownership races are isolated per operation so one completed claim does not stop the recovery pass. + +Port assignments are sticky metadata, while port ownership is a lifecycle lease. Stopped stacks +retain their assigned numbers without blocking other stopped stacks. Entering `starting`, +`running`, or `stopping` claims those ports host-wide; a collision fails without relocating a +sticky automatic assignment. On a stopped stack, exact configuration replaces persisted automatic +state, while an automatic request reuses the current number and changes only its intent. Running +port changes are reported as drift instead of overwriting the active assignment. + +Explicit deletion re-reads lifecycle after claiming the operation, safely stops when needed, +tombstones, and removes only the UUID-derived selected stack root. Repeating deletion retries any +leftover tombstoned data reclamation. Prune removes checkout location metadata only. Runtime +qualification, legacy bootstrap selection, and credential resolution remain callers of this +persistence boundary and are composed by later CLI slices. ## Legacy daemon paths diff --git a/packages/stack/package.json b/packages/stack/package.json index fbc048c6aa..b0706f7e09 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -60,7 +60,8 @@ "oxlint-tsgolint" ], "ignoreBinaries": [ - "nx" + "nx", + "ps" ] } } diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts index bd180634ba..621c341e1f 100644 --- a/packages/stack/src/managed-bun.ts +++ b/packages/stack/src/managed-bun.ts @@ -17,6 +17,7 @@ export interface CreateManagedStackServiceOptions { readonly ownerPid?: number; readonly publicationTimeoutMs?: number; readonly publicationPollMs?: number; + readonly isProcessAlive?: (pid: number) => boolean | Promise; } export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => { @@ -31,5 +32,6 @@ export const createManagedStackService = (options: CreateManagedStackServiceOpti ownerPid: options.ownerPid, publicationTimeoutMs: options.publicationTimeoutMs, publicationPollMs: options.publicationPollMs, + isProcessAlive: options.isProcessAlive, }); }; diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts index 0706036db1..684d29bbb0 100644 --- a/packages/stack/src/managed-node.ts +++ b/packages/stack/src/managed-node.ts @@ -17,6 +17,7 @@ export interface CreateManagedStackServiceOptions { readonly ownerPid?: number; readonly publicationTimeoutMs?: number; readonly publicationPollMs?: number; + readonly isProcessAlive?: (pid: number) => boolean | Promise; } export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => { @@ -31,5 +32,6 @@ export const createManagedStackService = (options: CreateManagedStackServiceOpti ownerPid: options.ownerPid, publicationTimeoutMs: options.publicationTimeoutMs, publicationPollMs: options.publicationPollMs, + isProcessAlive: options.isProcessAlive, }); }; diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index 65828fdf41..a08eccb40b 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { managedStackPaths, resolveManagedStateRoot } from "./managed/paths.ts"; +import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./managed/model.ts"; +import { + assertManagedStackRoot, + managedStackPaths, + resolveManagedStateRoot, +} from "./managed/paths.ts"; describe("managed paths", () => { it("isolates managed records beneath SUPABASE_HOME", () => { @@ -19,6 +24,20 @@ describe("managed paths", () => { expect(resolveManagedStateRoot({ env: {}, homeDir: "/Users/user", platform: "darwin" })).toBe( "/Users/user/Library/Application Support/supabase/managed", ); + expect( + resolveManagedStateRoot({ + env: { XDG_STATE_HOME: "" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/home/user/.local/state/supabase/managed"); + expect( + resolveManagedStateRoot({ + env: { LOCALAPPDATA: "" }, + homeDir: "C:\\Users\\user", + platform: "win32", + }), + ).toBe("C:\\Users\\user/AppData/Local/Supabase/managed"); }); it("keys every mutable stack path by opaque stack ID", () => { @@ -29,4 +48,13 @@ describe("managed paths", () => { runtime: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d/runtime", }); }); + + it("rejects non-UUID IDs and registry paths that do not match the derived root", () => { + expect(() => managedStackPaths("/state", "../../tmp/escaped")).toThrow( + InvalidManagedIdentityError, + ); + expect(() => + assertManagedStackRoot("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d", "/tmp/escaped"), + ).toThrow(UnsafeManagedStackPathError); + }); }); diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index aa4e5d0410..f1a574bcdd 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -1,18 +1,41 @@ import { Database } from "bun:sqlite"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { managedStackContractFixtures } from "./managed-stack-contract.ts"; -import { ordinaryWorkspaceIdentityPath } from "./managed/paths.ts"; +import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; +import { managedStackPaths, ordinaryWorkspaceIdentityPath } from "./managed/paths.ts"; import { + DuplicateManagedIdentityError, InvalidManagedIdentityError, + ManagedOperationInProgressError, ManagedPortReservationError, + ManagedRunningStackPortChangeError, ManagedStackInitializationError, + ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, UnsupportedManagedRegistryVersionError, } from "./managed/model.ts"; -import { createInMemoryManagedStackRepository } from "./managed/repository.ts"; -import { makeManagedStackService, type ManagedStackService } from "./managed/service.ts"; +import { + createInMemoryManagedStackRepository, + type ManagedStackRepository, +} from "./managed/repository.ts"; +import { + makeManagedStackService, + type ManagedStackService, + type ManagedStackServiceOptions, +} from "./managed/service.ts"; import { openBunSqliteManagedStackRepository } from "./managed/sqlite-bun.ts"; const temporaryRoots: Array = []; @@ -35,19 +58,43 @@ const makeWorkspace = (root: string, name = "workspace"): string => { return workspace; }; -const makeInMemoryService = (root: string): ManagedStackService => +const findNodeBinary = (): string => { + const executable = process.platform === "win32" ? "node.exe" : "node"; + for (const directory of (process.env["PATH"] ?? "").split(delimiter)) { + const candidate = join(directory, executable); + if (!existsSync(candidate)) { + continue; + } + const result = Bun.spawnSync([candidate, "--version"]); + const version = new TextDecoder().decode(result.stdout).trim(); + if (result.exitCode === 0 && /^v\d+\./.test(version)) { + return candidate; + } + } + throw new Error("Node is required for the managed SQLite adapter test"); +}; + +type ServiceOverrides = Omit; + +const makeInMemoryService = (root: string, overrides: ServiceOverrides = {}): ManagedStackService => makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), publicationPollMs: 1, + ...overrides, }); -const makePersistentService = (root: string): ManagedStackService => { +const makePersistentService = ( + root: string, + overrides: ServiceOverrides = {}, +): ManagedStackService => { const stateRoot = join(root, "managed"); + const databasePath = join(stateRoot, "registry-v1.sqlite3"); return makeManagedStackService({ - repository: openBunSqliteManagedStackRepository(join(stateRoot, "registry-v1.sqlite3")), + repository: openBunSqliteManagedStackRepository(databasePath), stateRoot, publicationPollMs: 1, + ...overrides, }); }; @@ -59,6 +106,53 @@ const fixture = (id: string) => { return scenario; }; +const portFacts = (id: string) => + fixture(id).given.flatMap((fact) => (fact.kind === "config-port" ? [fact] : [])); + +const portAssignmentFacts = (id: string) => + fixture(id).given.flatMap((fact) => (fact.kind === "port-assignment" ? [fact] : [])); + +const requirePortFact = (id: string, key: string) => { + const fact = portFacts(id).find((candidate) => candidate.key === key); + if (fact === undefined || !("value" in fact) || typeof fact.value !== "number") { + throw new Error(`Fixture ${id} does not define ${key}`); + } + return { key: fact.key, port: fact.value, intent: fact.intent }; +}; + +const stackNames = (id: string): ReadonlyArray => + fixture(id).given.flatMap((fact) => (fact.kind === "stack-names" ? fact.names : [])); + +const invalidStackNameCases = managedStackContractFixtures + .filter(({ id }) => id.startsWith("identity.invalid-stack-name-")) + .flatMap((scenario) => stackNames(scenario.id).map((name) => [scenario.id, name] as const)); + +const prepareAbandonedStack = async ( + service: ManagedStackService, + workspace: string, + ownerPid: number, +) => { + const identity = (await ensureOrdinaryWorkspaceIdentity(workspace)).identity; + const stackId = crypto.randomUUID(); + const prepared = service.repository.prepareOrdinaryStack({ + identity, + canonicalPath: realpathSync(workspace), + locationId: crypto.randomUUID(), + stackId, + stackName: "default", + paths: managedStackPaths(service.stateRoot, stackId), + operationToken: crypto.randomUUID(), + ownerPid, + now: "2026-08-11T00:00:00.000Z", + configuration: {}, + }); + if (prepared.outcome !== "create") { + throw new Error("Expected an abandoned pending stack"); + } + mkdirSync(prepared.stack.paths.data, { recursive: true }); + return prepared; +}; + describe("ordinary-folder managed stack contract", () => { it("keeps read-only discovery registration-free", async () => { const root = makeRoot(); @@ -96,6 +190,34 @@ describe("ordinary-folder managed stack contract", () => { expect(service.repository.listCheckoutLocations()).toEqual([]); }); + it.each(invalidStackNameCases)("rejects %s", async (_fixtureId, stackName) => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makeInMemoryService(root); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace, stackName }), + ).rejects.toThrow(`Invalid managed stack name: ${stackName}`); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); + expect(service.listStacks()).toEqual([]); + }); + + it("resolves every valid fixture stack name within one ordinary context", async () => { + const names = stackNames("identity.valid-stack-names-resolve-deterministically"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makeInMemoryService(root); + + const results = await Promise.all( + names.map((stackName) => + service.provisionOrdinaryStack({ workspacePath: workspace, stackName }), + ), + ); + + expect(results.map(({ stack }) => stack.name)).toEqual(names); + expect(new Set(results.map(({ stack }) => stack.id)).size).toBe(names.length); + }); + it("executes the first-start and persisted-identity M1 fixtures against SQLite", async () => { const firstStart = fixture("identity.non-git-folder-first-start-persists-identity"); const recoveredStart = fixture("identity.non-git-folder-recovers-persisted-identity"); @@ -243,10 +365,64 @@ describe("ordinary-folder managed stack contract", () => { expect(service.listStacks()).toHaveLength(1); service.close(); }); + + it("rejects a copied ordinary-folder identity claim", async () => { + fixture("identity.copied-checkout-reports-duplicate-claim"); + const root = makeRoot(); + const firstWorkspace = makeWorkspace(root, "first"); + const secondWorkspace = makeWorkspace(root, "copy"); + const service = makePersistentService(root); + await service.provisionOrdinaryStack({ workspacePath: firstWorkspace }); + mkdirSync(join(secondWorkspace, ".supabase"), { recursive: true }); + copyFileSync( + ordinaryWorkspaceIdentityPath(firstWorkspace), + ordinaryWorkspaceIdentityPath(secondWorkspace), + ); + + await expect( + service.provisionOrdinaryStack({ workspacePath: secondWorkspace }), + ).rejects.toBeInstanceOf(DuplicateManagedIdentityError); + expect(service.listStacks()).toHaveLength(1); + expect(service.repository.listCheckoutLocations()).toHaveLength(1); + service.close(); + }); + + it("times out without adopting a pending stack owned by another caller", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makePersistentService(root, { + publicationTimeoutMs: 2, + publicationPollMs: 1, + }); + await prepareAbandonedStack(service, workspace, process.pid); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedStackPublicationTimeoutError); + expect(service.listStacks()).toHaveLength(1); + service.close(); + }); + + it("rejects a non-UUID stack factory result before deriving state paths", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + await ensureOrdinaryWorkspaceIdentity(workspace); + const service = makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + idFactory: () => "../../outside", + }); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(InvalidManagedIdentityError); + expect(existsSync(join(root, "outside"))).toBe(false); + expect(service.listStacks()).toEqual([]); + }); }); describe("managed repository and lifecycle", () => { - for (const adapter of ["in-memory", "sqlite"] as const) { + for (const adapter of ["in-memory", "bun-sqlite"] as const) { it(`keeps repository decisions storage-agnostic for the ${adapter} adapter`, async () => { const contract = fixture("api-boundary.repository-contract-is-storage-agnostic"); const root = makeRoot(); @@ -303,6 +479,7 @@ describe("managed repository and lifecycle", () => { await expect( service.updateStack(second.stack.id, { + lifecycle: "starting", ports: [{ key: "db.port", port: 54_322, intent: "exact" }], }), ).rejects.toBeInstanceOf(ManagedPortReservationError); @@ -315,14 +492,20 @@ describe("managed repository and lifecycle", () => { const service = makeInMemoryService(root); await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "first"), - configuration: { ports: [{ key: "api.port", port: 54_321, intent: "exact" }] }, + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 54_321, intent: "exact" }], + }, }); const secondWorkspace = makeWorkspace(root, "second"); await expect( service.provisionOrdinaryStack({ workspacePath: secondWorkspace, - configuration: { ports: [{ key: "api.port", port: 54_321, intent: "exact" }] }, + configuration: { + lifecycle: "starting", + ports: [{ key: "api.port", port: 54_321, intent: "exact" }], + }, }), ).rejects.toBeInstanceOf(ManagedPortReservationError); expect(service.repository.listCheckoutLocations()).toHaveLength(1); @@ -335,7 +518,7 @@ describe("managed repository and lifecycle", () => { it("requires actual runtime inspection before recovering an abandoned operation", async () => { const root = makeRoot(); - const service = makeInMemoryService(root); + const service = makeInMemoryService(root, { isProcessAlive: () => false }); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); @@ -343,6 +526,7 @@ describe("managed repository and lifecycle", () => { token: crypto.randomUUID(), stackId: created.stack.id, kind: "start", + ownerPid: 987_654, now: "2026-08-11T00:00:00.000Z", }); if (!claimed.acquired) { @@ -359,6 +543,7 @@ describe("managed repository and lifecycle", () => { inspectRuntime: async () => "unknown", }); expect(unknown.recovered).toEqual([]); + expect(unknown.abortedStackIds).toEqual([]); expect(unknown.retained).toEqual([claimed.operation]); expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("starting"); @@ -366,10 +551,305 @@ describe("managed repository and lifecycle", () => { inspectRuntime: async () => "stopped", }); expect(reconciled.retained).toEqual([]); + expect(reconciled.abortedStackIds).toEqual([]); expect(reconciled.recovered).toHaveLength(1); expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("stopped"); }); + it("aborts a crashed pending provision and makes the identity retryable", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makePersistentService(root, { + isProcessAlive: () => false, + }); + const pending = await prepareAbandonedStack(service, workspace, 987_650); + writeFileSync(join(pending.stack.paths.data, "partial"), "incomplete"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + expect(reconciled.abortedStackIds).toEqual([pending.stack.id]); + expect(reconciled.recovered).toEqual([]); + expect(reconciled.retained).toEqual([]); + expect(existsSync(pending.stack.paths.root)).toBe(false); + expect(service.listStacks()).toEqual([]); + + const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(retried.outcome).toBe("create"); + expect(retried.stack.id).not.toBe(pending.stack.id); + service.close(); + }); + + it("publishes a crashed pending provision when runtime inspection finds it running", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makePersistentService(root, { + isProcessAlive: () => false, + }); + const pending = await prepareAbandonedStack(service, workspace, 987_651); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.recovered).toHaveLength(1); + expect(reconciled.recovered[0]).toMatchObject({ status: "active", lifecycle: "running" }); + const reused = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.id).toBe(pending.stack.id); + service.close(); + }); + + it("retains operations while their owner process is still alive", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root, { + isProcessAlive: (pid) => pid === 987_652, + }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_652); + let inspected = false; + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => { + inspected = true; + return "stopped"; + }, + }); + + expect(inspected).toBe(false); + expect(reconciled.retained).toEqual([pending.operation]); + expect(service.inspectStack(pending.stack.id)?.status).toBe("pending"); + }); + + it("continues recovery when an owner finishes one operation during inspection", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root, { isProcessAlive: () => false }); + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "second"), + }); + const firstOperation = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: first.stack.id, + kind: "start", + ownerPid: 987_653, + now: "2026-08-11T00:00:00.000Z", + }); + const secondOperation = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: second.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:01.000Z", + }); + if (!firstOperation.acquired || !secondOperation.acquired) { + throw new Error("Expected both recovery operations to be claimed"); + } + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async (stack, operation) => { + if (stack.id === first.stack.id) { + service.repository.finishOperation( + stack.id, + operation.token, + "completed", + "2026-08-11T00:00:02.000Z", + ); + } + return "stopped"; + }, + }); + + expect(reconciled.retained).toEqual([]); + expect(reconciled.recovered.map((stack) => stack.id)).toEqual([second.stack.id]); + }); + + it("applies exact stopped-stack ports and makes removed exact keys sticky", async () => { + const changedFixtureId = "ports.config-change-on-stopped-stack-applies"; + const removedFixtureId = "ports.removing-exact-key-keeps-current-port-sticky"; + const previous = portAssignmentFacts(changedFixtureId)[0]; + const requested = requirePortFact(changedFixtureId, "api.port"); + if (previous === undefined) { + throw new Error(`Fixture ${changedFixtureId} has no persisted assignment`); + } + const root = makeRoot(); + const service = makePersistentService(root); + await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { + ports: [{ key: previous.key, port: previous.port, intent: previous.intent }], + }, + }); + + const changed = await service.provisionOrdinaryStack({ + workspacePath: join(root, "workspace"), + configuration: { ports: [requested] }, + }); + expect(changed.outcome).toBe("reuse"); + expect(changed.stack.ports).toEqual([requested]); + + const removed = portFacts(removedFixtureId).find((fact) => fact.key === "api.port"); + if (removed === undefined) { + throw new Error(`Fixture ${removedFixtureId} has no api.port intent`); + } + const sticky = await service.provisionOrdinaryStack({ + workspacePath: join(root, "workspace"), + configuration: { + ports: [{ key: removed.key, port: 60_000, intent: removed.intent }], + }, + }); + expect(sticky.outcome).toBe("reuse"); + expect(sticky.stack.ports).toEqual([{ ...requested, intent: "automatic" }]); + service.close(); + }); + + it("rejects port drift while running without overwriting persisted exact intent", async () => { + const fixtureId = "ports.config-change-on-running-stack-reports-drift"; + const previous = portAssignmentFacts(fixtureId)[0]; + const requested = requirePortFact(fixtureId, "api.port"); + if (previous === undefined) { + throw new Error(`Fixture ${fixtureId} has no persisted assignment`); + } + const root = makeRoot(); + const service = makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { + lifecycle: "running", + ports: [{ key: previous.key, port: previous.port, intent: previous.intent }], + }, + }); + + await expect( + service.updateStack(created.stack.id, { ports: [requested] }), + ).rejects.toBeInstanceOf(ManagedRunningStackPortChangeError); + expect(service.inspectStack(created.stack.id)?.ports).toEqual([ + { key: previous.key, port: previous.port, intent: previous.intent }, + ]); + service.close(); + }); + + it("keeps stopped sticky assignments soft and claims them only while starting", async () => { + fixture("ports.sticky-ports-reuse-on-return"); + fixture("ports.later-sticky-port-collision-fails"); + const root = makeRoot(); + const service = makePersistentService(root); + const assignment = { key: "api.port", port: 55_421, intent: "automatic" as const }; + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + configuration: { ports: [assignment] }, + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "second"), + configuration: { ports: [assignment] }, + }); + + await service.updateStack(first.stack.id, { lifecycle: "starting" }); + await expect( + service.updateStack(second.stack.id, { lifecycle: "starting" }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + + await service.updateStack(first.stack.id, { lifecycle: "stopped" }); + const startedSecond = await service.updateStack(second.stack.id, { lifecycle: "starting" }); + expect(startedSecond.ports).toEqual([assignment]); + service.close(); + }); + + it("reports duplicate ports inside one stack as a managed reservation error", async () => { + const root = makeRoot(); + const service = makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + + await expect( + service.updateStack(created.stack.id, { + lifecycle: "starting", + ports: [ + { key: "api.port", port: 55_421, intent: "automatic" }, + { key: "db.port", port: 55_421, intent: "automatic" }, + ], + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect(service.inspectStack(created.stack.id)?.ports).toEqual([]); + service.close(); + }); + + it("rejects a second operation claim without mutating the stack", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + throw new Error("Expected the first operation claim to succeed"); + } + + await expect( + service.updateStack(created.stack.id, { lifecycle: "running" }), + ).rejects.toBeInstanceOf(ManagedOperationInProgressError); + expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("stopped"); + }); + + it("re-reads lifecycle after claiming delete before deciding whether to stop", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let promoteBeforeDelete = true; + const racingRepository: ManagedStackRepository = { + ...repository, + claimOperation(input) { + if (input.kind === "delete" && promoteBeforeDelete) { + promoteBeforeDelete = false; + const start = repository.claimOperation({ + token: crypto.randomUUID(), + stackId: input.stackId, + kind: "start", + ownerPid: 123, + now: input.now, + }); + if (!start.acquired) { + throw new Error("Expected the racing start operation to be claimed"); + } + repository.updateStack({ + stackId: input.stackId, + operationToken: start.operation.token, + lifecycle: "running", + now: input.now, + }); + repository.finishOperation(input.stackId, start.operation.token, "completed", input.now); + } + return repository.claimOperation(input); + }, + }; + const service = makeManagedStackService({ + repository: racingRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + let stoppedLifecycle: string | undefined; + + await service.deleteStack(created.stack.id, { + stop: async (stack) => { + stoppedLifecycle = stack.lifecycle; + }, + }); + + expect(stoppedLifecycle).toBe("running"); + expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); + }); + it("stops, tombstones, and reclaims one opaque stack ID idempotently", async () => { const contract = fixture("reclamation.delete-repeat-is-idempotent"); const root = makeRoot(); @@ -386,6 +866,8 @@ describe("managed repository and lifecycle", () => { stoppedStackId = stack.id; }, }); + mkdirSync(created.stack.paths.data, { recursive: true }); + writeFileSync(join(created.stack.paths.data, "orphaned-after-delete"), "retry removal"); const repeated = await service.deleteStack(created.stack.id); expect(deleted.outcome).toBe("delete"); @@ -397,6 +879,47 @@ describe("managed repository and lifecycle", () => { service.close(); }); + it("refuses tombstone reclamation outside the UUID-derived managed root", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let forgePath = false; + const outsideRoot = join(root, "outside"); + mkdirSync(outsideRoot); + writeFileSync(join(outsideRoot, "preserve"), "safe"); + const guardedRepository: ManagedStackRepository = { + ...repository, + getStack(stackId) { + const stack = repository.getStack(stackId); + if (stack === undefined || !forgePath) { + return stack; + } + return { + ...stack, + paths: { + root: outsideRoot, + data: join(outsideRoot, "data"), + logs: join(outsideRoot, "logs"), + runtime: join(outsideRoot, "runtime"), + }, + }; + }, + }; + const service = makeManagedStackService({ + repository: guardedRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + await service.deleteStack(created.stack.id); + forgePath = true; + + await expect(service.deleteStack(created.stack.id)).rejects.toBeInstanceOf( + UnsafeManagedStackPathError, + ); + expect(readFileSync(join(outsideRoot, "preserve"), "utf8")).toBe("safe"); + }); + it("prunes checkout location metadata without touching stack data", async () => { const contract = fixture("reclamation.prune-removes-metadata-only"); const root = makeRoot(); @@ -417,6 +940,79 @@ describe("managed repository and lifecycle", () => { service.close(); }); + it("persists and reuses managed state through the real Node SQLite adapter", async () => { + const root = makeRoot(); + const stateRoot = join(root, "node-managed"); + const workspace = makeWorkspace(root, "node-workspace"); + const adapterUrl = pathToFileURL(join(process.cwd(), "src/managed/sqlite-node.ts")).href; + const serviceUrl = pathToFileURL(join(process.cwd(), "src/managed/service.ts")).href; + const source = ` + import assert from "node:assert/strict"; + import { randomUUID } from "node:crypto"; + import { openNodeSqliteManagedStackRepository } from ${JSON.stringify(adapterUrl)}; + import { makeManagedStackService } from ${JSON.stringify(serviceUrl)}; + const stateRoot = ${JSON.stringify(stateRoot)}; + const workspacePath = ${JSON.stringify(workspace)}; + const databasePath = ${JSON.stringify(join(stateRoot, "registry-v1.sqlite3"))}; + const firstRepository = openNodeSqliteManagedStackRepository(databasePath); + assert.equal(firstRepository.getStack(randomUUID()), undefined); + const firstService = makeManagedStackService({ repository: firstRepository, stateRoot }); + const first = await firstService.provisionOrdinaryStack({ workspacePath }); + firstService.close(); + const secondRepository = openNodeSqliteManagedStackRepository(databasePath); + const secondService = makeManagedStackService({ repository: secondRepository, stateRoot }); + const second = await secondService.provisionOrdinaryStack({ workspacePath }); + assert.equal(first.outcome, "create"); + assert.equal(second.outcome, "reuse"); + assert.equal(second.stack.id, first.stack.id); + secondService.close(); + `; + const command = [ + findNodeBinary(), + "--no-warnings", + "--experimental-transform-types", + "--input-type=module", + "--eval", + source, + ]; + const child = Bun.spawn(command, { + stdout: "ignore", + stderr: "pipe", + }); + + const exitCode = await child.exited; + const stderr = await new Response(child.stderr).text(); + + expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); + }); + + it("initializes one fresh registry safely across concurrent Bun processes", async () => { + const root = makeRoot(); + const databasePath = join(root, "cold", "registry-v1.sqlite3"); + const adapterUrl = pathToFileURL(join(process.cwd(), "src/managed/sqlite-bun.ts")).href; + const source = ` + import { openBunSqliteManagedStackRepository } from ${JSON.stringify(adapterUrl)}; + const repository = openBunSqliteManagedStackRepository(${JSON.stringify(databasePath)}); + repository.listStacks(); + repository.close(); + `; + const children = Array.from({ length: 8 }, () => + Bun.spawn([process.execPath, "--eval", source], { stdout: "ignore", stderr: "pipe" }), + ); + + const results = await Promise.all( + children.map(async (child) => ({ + exitCode: await child.exited, + stderr: await new Response(child.stderr).text(), + })), + ); + + expect(results).toEqual(Array.from({ length: 8 }, () => ({ exitCode: 0, stderr: "" }))); + const repository = openBunSqliteManagedStackRepository(databasePath); + expect(repository.listStacks()).toEqual([]); + repository.close(); + }); + it("fails safely when a registry has a newer schema version", () => { const root = makeRoot(); const databasePath = join(root, "future.sqlite3"); diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts index 83665d8b98..46d14f788b 100644 --- a/packages/stack/src/managed.ts +++ b/packages/stack/src/managed.ts @@ -1,4 +1,5 @@ export * from "./managed/identity.ts"; +export * from "./managed/ids.ts"; export * from "./managed/model.ts"; export * from "./managed/paths.ts"; export * from "./managed/repository.ts"; diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index bb3311b30f..d895a621ea 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -6,10 +6,9 @@ import { ORDINARY_WORKSPACE_IDENTITY_VERSION, type OrdinaryWorkspaceIdentity, } from "./model.ts"; +import { assertManagedUuid, createManagedUuid } from "./ids.ts"; import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const errorCode = (error: unknown): string | undefined => { if (typeof error !== "object" || error === null) { return undefined; @@ -23,10 +22,10 @@ const identityField = (value: unknown, field: string): string => { throw new InvalidManagedIdentityError("The ordinary workspace identity must be an object"); } const fieldValue = Reflect.get(value, field); - if (typeof fieldValue !== "string" || !UUID_PATTERN.test(fieldValue)) { + if (typeof fieldValue !== "string") { throw new InvalidManagedIdentityError(`${field} must be an opaque UUID`); } - return fieldValue; + return assertManagedUuid(fieldValue, field); }; const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { @@ -93,18 +92,13 @@ export const ensureOrdinaryWorkspaceIdentity = async ( const identity: OrdinaryWorkspaceIdentity = { version: ORDINARY_WORKSPACE_IDENTITY_VERSION, - projectId: idFactory(), - checkoutId: idFactory(), - contextId: idFactory(), + projectId: createManagedUuid(idFactory, "projectId"), + checkoutId: createManagedUuid(idFactory, "checkoutId"), + contextId: createManagedUuid(idFactory, "contextId"), }; - for (const id of [identity.projectId, identity.checkoutId, identity.contextId]) { - if (!UUID_PATTERN.test(id)) { - throw new InvalidManagedIdentityError(`Identity factory returned a non-UUID value: ${id}`); - } - } await mkdir(dirname(markerPath), { recursive: true }); - const temporaryPath = `${markerPath}.tmp.${idFactory()}`; + const temporaryPath = `${markerPath}.tmp.${createManagedUuid(idFactory, "identity temporary id")}`; await writeFile(temporaryPath, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 }); try { await link(temporaryPath, markerPath); diff --git a/packages/stack/src/managed/ids.ts b/packages/stack/src/managed/ids.ts new file mode 100644 index 0000000000..96af6acad8 --- /dev/null +++ b/packages/stack/src/managed/ids.ts @@ -0,0 +1,13 @@ +import { InvalidManagedIdentityError } from "./model.ts"; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export const assertManagedUuid = (value: string, label: string): string => { + if (!UUID_PATTERN.test(value)) { + throw new InvalidManagedIdentityError(`${label} must be an opaque UUID`); + } + return value; +}; + +export const createManagedUuid = (idFactory: () => string, label: string): string => + assertManagedUuid(idFactory(), label); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index dad398c928..d365b8e743 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -122,12 +122,12 @@ export class DuplicateManagedIdentityError extends ManagedStackError { readonly code = "DUPLICATE_MANAGED_IDENTITY"; constructor( - readonly checkoutId: string, - readonly existingPath: string, - readonly requestedPath: string, + readonly identityId: string, + readonly existingClaim: string, + readonly requestedClaim: string, ) { super( - `Checkout ${checkoutId} is already registered at ${existingPath}; refusing a second claim from ${requestedPath}`, + `Managed identity ${identityId} is already claimed by ${existingClaim}; refusing a second claim from ${requestedClaim}`, ); this.name = "DuplicateManagedIdentityError"; } @@ -175,12 +175,31 @@ export class ManagedPortReservationError extends ManagedStackError { } } +export class ManagedRunningStackPortChangeError extends ManagedStackError { + readonly code = "MANAGED_RUNNING_STACK_PORT_CHANGE"; + + constructor(readonly stackId: string) { + super(`Managed stack ${stackId} must be stopped before changing its persisted ports`); + this.name = "ManagedRunningStackPortChangeError"; + } +} + +export class UnsafeManagedStackPathError extends ManagedStackError { + readonly code = "UNSAFE_MANAGED_STACK_PATH"; + + constructor(readonly path: string) { + super(`Refusing to remove an unsafe managed stack path: ${path}`); + this.name = "UnsafeManagedStackPathError"; + } +} + export class ManagedStackInitializationError extends ManagedStackError { readonly code = "MANAGED_STACK_INITIALIZATION_FAILED"; constructor( readonly stackId: string, override readonly cause: unknown, + readonly cleanupErrors: ReadonlyArray = [], ) { super(`Managed stack ${stackId} could not be initialized`); this.name = "ManagedStackInitializationError"; diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index 5e2253a8b5..3cb5dace7c 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; -import { join } from "node:path"; -import type { ManagedStackPaths } from "./model.ts"; +import { join, resolve } from "node:path"; +import { assertManagedUuid } from "./ids.ts"; +import { UnsafeManagedStackPathError, type ManagedStackPaths } from "./model.ts"; export interface ManagedStateRootOptions { readonly stateRoot?: string; @@ -9,14 +10,17 @@ export interface ManagedStateRootOptions { readonly platform?: NodeJS.Platform; } +const nonEmpty = (value: string | undefined): string | undefined => + value === undefined || value.length === 0 ? undefined : value; + export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): string => { if (options.stateRoot !== undefined) { return options.stateRoot; } const env = options.env ?? process.env; - const configuredHome = env["SUPABASE_HOME"]; - if (configuredHome !== undefined && configuredHome.length > 0) { + const configuredHome = nonEmpty(env["SUPABASE_HOME"]); + if (configuredHome !== undefined) { return join(configuredHome, "managed"); } @@ -26,11 +30,11 @@ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): return join(userHome, "Library", "Application Support", "supabase", "managed"); } if (platform === "win32") { - const localAppData = env["LOCALAPPDATA"]; + const localAppData = nonEmpty(env["LOCALAPPDATA"]); return join(localAppData ?? join(userHome, "AppData", "Local"), "Supabase", "managed"); } - const stateHome = env["XDG_STATE_HOME"]; + const stateHome = nonEmpty(env["XDG_STATE_HOME"]); return join(stateHome ?? join(userHome, ".local", "state"), "supabase", "managed"); }; @@ -38,6 +42,7 @@ export const managedRegistryPath = (stateRoot: string): string => join(stateRoot, "registry-v1.sqlite3"); export const managedStackPaths = (stateRoot: string, stackId: string): ManagedStackPaths => { + assertManagedUuid(stackId, "stackId"); const root = join(stateRoot, "stacks", stackId); return { root, @@ -47,5 +52,18 @@ export const managedStackPaths = (stateRoot: string, stackId: string): ManagedSt }; }; +export const assertManagedStackRoot = ( + stateRoot: string, + stackId: string, + stackRoot: string, +): string => { + const expected = resolve(managedStackPaths(stateRoot, stackId).root); + const actual = resolve(stackRoot); + if (actual !== expected) { + throw new UnsafeManagedStackPathError(stackRoot); + } + return actual; +}; + export const ordinaryWorkspaceIdentityPath = (workspacePath: string): string => join(workspacePath, ".supabase", "identity.json"); diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index dc3f7196db..f1e7bac802 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -2,6 +2,7 @@ import { DuplicateManagedIdentityError, ManagedOperationOwnershipError, ManagedPortReservationError, + ManagedRunningStackPortChangeError, ManagedStackNotFoundError, type ManagedCheckoutLocation, type ManagedOperationKind, @@ -85,7 +86,7 @@ export interface ManagedStackRepository { operationToken: string, lifecycle: ManagedStackLifecycle, now: string, - ): ManagedStackRecord; + ): ManagedStackRecord | undefined; tombstoneStack(stackId: string, operationToken: string, now: string): ManagedStackRecord; listCheckoutLocations(): ReadonlyArray; pruneCheckoutLocations(locationIds: ReadonlyArray): number; @@ -107,6 +108,68 @@ const stackIdentityKey = (checkoutId: string, contextId: string, stackName: stri const copy = (value: A): A => structuredClone(value); +const portsEqual = ( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean => { + if (left.length !== right.length) { + return false; + } + const byKey = new Map(right.map((assignment) => [assignment.key, assignment])); + return left.every((assignment) => { + const candidate = byKey.get(assignment.key); + return ( + candidate !== undefined && + assignment.port === candidate.port && + assignment.intent === candidate.intent + ); + }); +}; + +export const validateManagedPortAssignments = ( + stackId: string, + ports: ReadonlyArray, +): void => { + const keys = new Set(); + const numbers = new Set(); + for (const assignment of ports) { + if (!Number.isInteger(assignment.port) || assignment.port < 1 || assignment.port > 65_535) { + throw new Error(`Invalid managed port ${assignment.port} for ${assignment.key}`); + } + if (keys.has(assignment.key)) { + throw new Error(`Duplicate managed port key ${assignment.key}`); + } + if (numbers.has(assignment.port)) { + throw new ManagedPortReservationError(assignment.port, stackId); + } + keys.add(assignment.key); + numbers.add(assignment.port); + } +}; + +export const reconcileManagedPortAssignments = ( + stack: ManagedStackRecord, + requested: ReadonlyArray | undefined, +): ReadonlyArray => { + if (requested === undefined) { + return stack.ports; + } + validateManagedPortAssignments(stack.id, requested); + if (stack.lifecycle !== "stopped") { + if (!portsEqual(stack.ports, requested)) { + throw new ManagedRunningStackPortChangeError(stack.id); + } + return stack.ports; + } + const persisted = new Map(stack.ports.map((assignment) => [assignment.key, assignment])); + return requested.map((assignment) => { + const current = persisted.get(assignment.key); + return assignment.intent === "automatic" && current !== undefined + ? { ...assignment, port: current.port } + : assignment; + }); +}; + const applyConfiguration = ( stack: ManagedStackRecord, configuration: ManagedStackConfiguration, @@ -116,7 +179,7 @@ const applyConfiguration = ( lifecycle: configuration.lifecycle ?? stack.lifecycle, runtimeRequest: configuration.runtimeRequest ?? stack.runtimeRequest, runtime: configuration.runtime ?? stack.runtime, - ports: configuration.ports ?? stack.ports, + ports: reconcileManagedPortAssignments(stack, configuration.ports), serviceVersions: configuration.serviceVersions ?? stack.serviceVersions, runtimeMetadata: configuration.runtimeMetadata ?? stack.runtimeMetadata, configFingerprint: configuration.configFingerprint ?? stack.configFingerprint, @@ -204,24 +267,37 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = return operation; }; - const reservePorts = ( - stackId: string, - current: ReadonlyArray, - next: ReadonlyArray, + const occupiesPorts = (lifecycle: ManagedStackLifecycle): boolean => + lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping"; + + const transitionPortOwnership = ( + current: ManagedStackRecord | undefined, + next: ManagedStackRecord, ): void => { - for (const assignment of next) { - const owner = portOwners.get(assignment.port); - if (owner !== undefined && owner !== stackId) { - throw new ManagedPortReservationError(assignment.port, owner); + validateManagedPortAssignments(next.id, next.ports); + if (occupiesPorts(next.lifecycle)) { + for (const assignment of next.ports) { + const owner = portOwners.get(assignment.port); + if (owner !== undefined && owner !== next.id) { + throw new ManagedPortReservationError(assignment.port, owner); + } } } - for (const assignment of current) { - if (portOwners.get(assignment.port) === stackId) { - portOwners.delete(assignment.port); + if (current !== undefined && occupiesPorts(current.lifecycle)) { + for (const assignment of current.ports) { + if (portOwners.get(assignment.port) === current.id) { + portOwners.delete(assignment.port); + } } } - for (const assignment of next) { - portOwners.set(assignment.port, stackId); + if (occupiesPorts(next.lifecycle)) { + for (const assignment of next.ports) { + const owner = portOwners.get(assignment.port); + if (owner !== undefined && owner !== next.id) { + throw new ManagedPortReservationError(assignment.port, owner); + } + portOwners.set(assignment.port, next.id); + } } }; @@ -269,9 +345,9 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = const context = contexts.get(input.identity.contextId); if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - context.checkoutId, input.identity.contextId, + context.checkoutId, + input.identity.checkoutId, ); } contexts.set(input.identity.contextId, { @@ -297,9 +373,9 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = ); if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - pathOwner.canonicalPath, input.canonicalPath, + pathOwner.checkoutId, + input.identity.checkoutId, ); } locations.set(existingLocation?.id ?? input.locationId, { @@ -344,7 +420,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = updatedAt: input.now, }; const stack = applyConfiguration(baseStack, input.configuration, input.now); - reservePorts(stack.id, [], stack.ports); + transitionPortOwnership(undefined, stack); stacks.set(stack.id, stack); stackIdentities.set(identityKey, stack.id); const claimed = claimOperation({ @@ -386,7 +462,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = if (stack.status !== "pending") { throw new ManagedOperationOwnershipError(stackId); } - reservePorts(stack.id, stack.ports, []); + transitionPortOwnership(stack, { ...stack, lifecycle: "stopped", ports: [] }); stacks.delete(stackId); stackIdentities.delete(stackIdentityKey(stack.checkoutId, stack.contextId, stack.name)); operations.delete(operationToken); @@ -425,7 +501,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = requireOwnedOperation(input.stackId, input.operationToken); const current = requireStack(input.stackId); const next = applyConfiguration(current, input, input.now); - reservePorts(current.id, current.ports, next.ports); + transitionPortOwnership(current, next); stacks.set(current.id, next); return copy(next); }, @@ -442,7 +518,23 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = reconcileOperation(stackId, operationToken, lifecycle, now) { const operation = requireOwnedOperation(stackId, operationToken); const current = requireStack(stackId); - const next: ManagedStackRecord = { ...current, lifecycle, updatedAt: now }; + if (current.status === "pending" && lifecycle === "stopped") { + transitionPortOwnership(current, { ...current, lifecycle: "stopped", ports: [] }); + stacks.delete(stackId); + stackIdentities.delete( + stackIdentityKey(current.checkoutId, current.contextId, current.name), + ); + operations.delete(operationToken); + activeOperationByStack.delete(stackId); + return undefined; + } + const next: ManagedStackRecord = { + ...current, + status: current.status === "pending" ? "active" : current.status, + lifecycle, + updatedAt: now, + }; + transitionPortOwnership(current, next); stacks.set(stackId, next); operations.set(operationToken, { ...operation, @@ -456,7 +548,6 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = tombstoneStack(stackId, operationToken, now) { requireOwnedOperation(stackId, operationToken); const current = requireStack(stackId); - reservePorts(current.id, current.ports, []); const next: ManagedStackRecord = { ...current, status: "tombstoned", @@ -466,6 +557,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = updatedAt: now, tombstonedAt: now, }; + transitionPortOwnership(current, next); stacks.set(stackId, next); stackIdentities.delete(stackIdentityKey(current.checkoutId, current.contextId, current.name)); return copy(next); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index e0513f7a87..e003107cec 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -4,6 +4,7 @@ import { DEFAULT_MANAGED_STACK_NAME, ManagedAbandonedOperationError, ManagedOperationInProgressError, + ManagedOperationOwnershipError, ManagedStackInitializationError, ManagedStackNotFoundError, ManagedStackPublicationTimeoutError, @@ -21,7 +22,8 @@ import { ensureOrdinaryWorkspaceIdentity, readOrdinaryWorkspaceIdentity, } from "./identity.ts"; -import { managedStackPaths } from "./paths.ts"; +import { createManagedUuid } from "./ids.ts"; +import { assertManagedStackRoot, managedStackPaths } from "./paths.ts"; import type { ManagedStackRepository } from "./repository.ts"; export interface ManagedStackServiceOptions { @@ -32,6 +34,7 @@ export interface ManagedStackServiceOptions { readonly ownerPid?: number; readonly publicationTimeoutMs?: number; readonly publicationPollMs?: number; + readonly isProcessAlive?: (pid: number) => boolean | Promise; } export interface ProvisionOrdinaryStackOptions { @@ -70,6 +73,7 @@ export interface ReconcileAbandonedOperationsOptions { export interface ReconcileAbandonedOperationsResult { readonly recovered: ReadonlyArray; + readonly abortedStackIds: ReadonlyArray; readonly retained: ReadonlyArray; } @@ -112,6 +116,23 @@ const wait = (milliseconds: number): Promise => const stackNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const errorCode = (error: unknown): string | undefined => { + if (typeof error !== "object" || error === null) { + return undefined; + } + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +}; + +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return errorCode(error) !== "ESRCH"; + } +}; + export const makeManagedStackService = ( options: ManagedStackServiceOptions, ): ManagedStackService => { @@ -120,14 +141,32 @@ export const makeManagedStackService = ( const ownerPid = options.ownerPid ?? process.pid; const publicationTimeoutMs = options.publicationTimeoutMs ?? 10_000; const publicationPollMs = options.publicationPollMs ?? 10; + const isProcessAlive = options.isProcessAlive ?? processIsAlive; const now = (): string => clock().toISOString(); + const removeStackState = async (stack: ManagedStackRecord): Promise => { + const root = assertManagedStackRoot(options.stateRoot, stack.id, stack.paths.root); + await rm(root, { force: true, recursive: true }); + }; + + const finishOperationBestEffort = ( + stackId: string, + operationToken: string, + error: unknown, + ): void => { + try { + options.repository.finishOperation(stackId, operationToken, "failed", now(), String(error)); + } catch { + // Preserve the operation's original failure when ownership changed concurrently. + } + }; + const requireOperation = ( stackId: string, kind: ManagedOperationKind, ): ManagedOperationRecord => { const claimed = options.repository.claimOperation({ - token: idFactory(), + token: createManagedUuid(idFactory, "operation token"), stackId, kind, ownerPid, @@ -140,8 +179,8 @@ export const makeManagedStackService = ( }; const awaitPublication = async (pending: ManagedStackRecord): Promise => { - const deadline = Date.now() + publicationTimeoutMs; - while (Date.now() <= deadline) { + const deadline = performance.now() + publicationTimeoutMs; + while (performance.now() <= deadline) { const current = options.repository.getStack(pending.id); if (current === undefined) { throw new ManagedAbandonedOperationError(pending.id); @@ -157,6 +196,26 @@ export const makeManagedStackService = ( throw new ManagedStackPublicationTimeoutError(pending.id); }; + const updateStackRecord = async ( + stackId: string, + configuration: ManagedStackConfiguration, + ): Promise => { + const operation = requireOperation(stackId, "update"); + try { + const stack = options.repository.updateStack({ + stackId, + operationToken: operation.token, + now: now(), + ...configuration, + }); + options.repository.finishOperation(stackId, operation.token, "completed", now()); + return stack; + } catch (error: unknown) { + finishOperationBestEffort(stackId, operation.token, error); + throw error; + } + }; + return { stateRoot: options.stateRoot, repository: options.repository, @@ -167,15 +226,15 @@ export const makeManagedStackService = ( } const canonicalPath = await canonicalizeOrdinaryWorkspacePath(provisionOptions.workspacePath); const marker = await ensureOrdinaryWorkspaceIdentity(canonicalPath, idFactory); - const stackId = idFactory(); + const stackId = createManagedUuid(idFactory, "stackId"); const prepared = options.repository.prepareOrdinaryStack({ identity: marker.identity, canonicalPath, - locationId: idFactory(), + locationId: createManagedUuid(idFactory, "checkout location id"), stackId, stackName, paths: managedStackPaths(options.stateRoot, stackId), - operationToken: idFactory(), + operationToken: createManagedUuid(idFactory, "operation token"), ownerPid, now: now(), configuration: provisionOptions.configuration ?? {}, @@ -183,10 +242,18 @@ export const makeManagedStackService = ( if (prepared.outcome === "existing") { if (prepared.stack.status === "active") { + if (prepared.operation !== undefined) { + throw new ManagedOperationInProgressError(prepared.stack.id, prepared.operation); + } + const stack = + provisionOptions.configuration === undefined || + Object.keys(provisionOptions.configuration).length === 0 + ? prepared.stack + : await updateStackRecord(prepared.stack.id, provisionOptions.configuration); return { outcome: "reuse", - selection: selectionForStack(prepared.stack), - stack: prepared.stack, + selection: selectionForStack(stack), + stack, identityMarkerCreated: marker.created, }; } @@ -220,11 +287,18 @@ export const makeManagedStackService = ( identityMarkerCreated: marker.created, }; } catch (cause: unknown) { - await rm(prepared.stack.paths.root, { force: true, recursive: true }).catch( - () => undefined, - ); - options.repository.abortPendingStack(prepared.stack.id, prepared.operation.token); - throw new ManagedStackInitializationError(prepared.stack.id, cause); + const cleanupErrors: Array = []; + try { + await removeStackState(prepared.stack); + } catch (error: unknown) { + cleanupErrors.push(error); + } + try { + options.repository.abortPendingStack(prepared.stack.id, prepared.operation.token); + } catch (error: unknown) { + cleanupErrors.push(error); + } + throw new ManagedStackInitializationError(prepared.stack.id, cause, cleanupErrors); } }, async inspectOrdinaryWorkspace(workspacePath) { @@ -237,7 +311,9 @@ export const makeManagedStackService = ( .listStacks() .filter( (stack) => - stack.projectId === identity.projectId && stack.checkoutId === identity.checkoutId, + stack.projectId === identity.projectId && + stack.checkoutId === identity.checkoutId && + stack.contextId === identity.contextId, ); return { registered: stacks.length > 0, identity, stacks }; }, @@ -248,26 +324,7 @@ export const makeManagedStackService = ( return options.repository.listStacks(listOptions); }, async updateStack(stackId, configuration) { - const operation = requireOperation(stackId, "update"); - try { - const stack = options.repository.updateStack({ - stackId, - operationToken: operation.token, - now: now(), - ...configuration, - }); - options.repository.finishOperation(stackId, operation.token, "completed", now()); - return stack; - } catch (error: unknown) { - options.repository.finishOperation( - stackId, - operation.token, - "failed", - now(), - String(error), - ); - throw error; - } + return updateStackRecord(stackId, configuration); }, async deleteStack(stackId, deleteOptions) { const existing = options.repository.getStack(stackId); @@ -275,15 +332,25 @@ export const makeManagedStackService = ( throw new ManagedStackNotFoundError(stackId); } if (existing.status === "tombstoned") { + await removeStackState(existing); return { outcome: "no-op", stack: existing }; } const operation = requireOperation(stackId, "delete"); try { - if (existing.lifecycle !== "stopped") { + const current = options.repository.getStack(stackId); + if (current === undefined) { + throw new ManagedStackNotFoundError(stackId); + } + if (current.status === "tombstoned") { + await removeStackState(current); + options.repository.finishOperation(stackId, operation.token, "completed", now()); + return { outcome: "no-op", stack: current }; + } + if (current.lifecycle !== "stopped") { if (deleteOptions?.stop === undefined) { throw new Error(`Managed stack ${stackId} must be safely stopped before deletion`); } - await deleteOptions.stop(existing); + await deleteOptions.stop(current); options.repository.updateStack({ stackId, operationToken: operation.token, @@ -293,42 +360,61 @@ export const makeManagedStackService = ( }); } const tombstoned = options.repository.tombstoneStack(stackId, operation.token, now()); - await rm(tombstoned.paths.root, { force: true, recursive: true }); + await removeStackState(tombstoned); options.repository.finishOperation(stackId, operation.token, "completed", now()); return { outcome: "delete", stack: tombstoned }; } catch (error: unknown) { - options.repository.finishOperation( - stackId, - operation.token, - "failed", - now(), - String(error), - ); + finishOperationBestEffort(stackId, operation.token, error); throw error; } }, async reconcileAbandonedOperations(reconcileOptions) { const recovered: Array = []; + const abortedStackIds: Array = []; const retained: Array = []; for (const operation of options.repository.listActiveOperations( reconcileOptions.startedBefore, )) { - const stack = options.repository.getStack(operation.stackId); - if (stack === undefined) { + if (operation.ownerPid === undefined || (await isProcessAlive(operation.ownerPid))) { retained.push(operation); continue; } - const actual = await reconcileOptions.inspectRuntime(stack, operation); - if (actual === "unknown") { + try { + const stack = options.repository.getStack(operation.stackId); + if (stack === undefined) { + continue; + } + const actual = await reconcileOptions.inspectRuntime(stack, operation); + if (actual === "unknown") { + retained.push(operation); + continue; + } + const lifecycle: ManagedStackLifecycle = actual === "running" ? "running" : "stopped"; + if (stack.status === "pending" && lifecycle === "stopped") { + await removeStackState(stack); + } + const reconciled = options.repository.reconcileOperation( + stack.id, + operation.token, + lifecycle, + now(), + ); + if (reconciled === undefined) { + abortedStackIds.push(stack.id); + } else { + recovered.push(reconciled); + } + } catch (error: unknown) { + if ( + error instanceof ManagedOperationOwnershipError || + error instanceof ManagedStackNotFoundError + ) { + continue; + } retained.push(operation); - continue; } - const lifecycle: ManagedStackLifecycle = actual === "running" ? "running" : "stopped"; - recovered.push( - options.repository.reconcileOperation(stack.id, operation.token, lifecycle, now()), - ); } - return { recovered, retained }; + return { recovered, abortedStackIds, retained }; }, async pruneCheckoutLocations(shouldPrune) { const stale: Array = []; diff --git a/packages/stack/src/managed/sqlite-node.ts b/packages/stack/src/managed/sqlite-node.ts index a2e848609e..28f16262a8 100644 --- a/packages/stack/src/managed/sqlite-node.ts +++ b/packages/stack/src/managed/sqlite-node.ts @@ -19,7 +19,7 @@ export const openNodeSqliteManagedStackRepository = (path: string) => { statement.run(...parameters); }, get(parameters = []) { - return statement.get(...parameters); + return statement.get(...parameters) ?? undefined; }, all(parameters = []) { return statement.all(...parameters); diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index d922cbced3..e3016dbb68 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -28,6 +28,7 @@ import type { PrepareOrdinaryStackResult, UpdateManagedStackInput, } from "./repository.ts"; +import { reconcileManagedPortAssignments, validateManagedPortAssignments } from "./repository.ts"; type SqliteValue = null | number | string; @@ -156,21 +157,63 @@ const managedPortIntent = (value: string): ManagedPortIntent => { throw new Error(`Unknown managed port intent ${value}`); }; -const initializeSchema = (database: ManagedSqliteDatabase): void => { - database.exec("PRAGMA foreign_keys = ON"); - database.exec("PRAGMA journal_mode = WAL"); - database.exec("PRAGMA busy_timeout = 5000"); - const versionRow = database.prepare("PRAGMA user_version").get(); - const version = getNumber(versionRow, "user_version"); - if (version > MANAGED_REGISTRY_SCHEMA_VERSION) { - throw new UnsupportedManagedRegistryVersionError(version, MANAGED_REGISTRY_SCHEMA_VERSION); +const sqliteErrorCode = (error: unknown): string | undefined => { + if (typeof error !== "object" || error === null) { + return undefined; } - if (version === MANAGED_REGISTRY_SCHEMA_VERSION) { - return; + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +}; + +const isSqliteBusy = (error: unknown): boolean => { + const code = sqliteErrorCode(error); + if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") { + return true; + } + return error instanceof Error && /database is (?:busy|locked)/i.test(error.message); +}; + +const synchronousWait = (milliseconds: number): void => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +}; + +const enableWriteAheadLogging = (database: ManagedSqliteDatabase): void => { + for (let attempt = 0; ; attempt += 1) { + try { + database.exec("PRAGMA journal_mode = WAL"); + return; + } catch (error: unknown) { + if (!isSqliteBusy(error) || attempt >= 49) { + throw error; + } + synchronousWait(Math.min(10 + attempt * 5, 100)); + } + } +}; + +const rollbackPreservingCause = (database: ManagedSqliteDatabase): void => { + try { + database.exec("ROLLBACK"); + } catch { + // The original transaction error is more useful than a secondary rollback failure. } +}; +const initializeSchema = (database: ManagedSqliteDatabase): void => { + database.exec("PRAGMA busy_timeout = 5000"); + database.exec("PRAGMA foreign_keys = ON"); + enableWriteAheadLogging(database); database.exec("BEGIN IMMEDIATE"); try { + const versionRow = database.prepare("PRAGMA user_version").get(); + const version = getNumber(versionRow, "user_version"); + if (version > MANAGED_REGISTRY_SCHEMA_VERSION) { + throw new UnsupportedManagedRegistryVersionError(version, MANAGED_REGISTRY_SCHEMA_VERSION); + } + if (version === MANAGED_REGISTRY_SCHEMA_VERSION) { + database.exec("COMMIT"); + return; + } database.exec(` CREATE TABLE projects ( id TEXT PRIMARY KEY, @@ -230,10 +273,11 @@ const initializeSchema = (database: ManagedSqliteDatabase): void => { CREATE TABLE ports ( stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE, key TEXT NOT NULL, - port INTEGER NOT NULL UNIQUE, + port INTEGER NOT NULL, intent TEXT NOT NULL CHECK (intent IN ('automatic', 'exact')), PRIMARY KEY (stack_id, key) ); + CREATE INDEX port_assignments_by_port ON ports(port); CREATE TABLE operations ( token TEXT PRIMARY KEY, @@ -253,7 +297,7 @@ const initializeSchema = (database: ManagedSqliteDatabase): void => { `); database.exec("COMMIT"); } catch (error: unknown) { - database.exec("ROLLBACK"); + rollbackPreservingCause(database); throw error; } }; @@ -265,7 +309,19 @@ const transaction = (database: ManagedSqliteDatabase, run: () => A): A => { database.exec("COMMIT"); return result; } catch (error: unknown) { - database.exec("ROLLBACK"); + rollbackPreservingCause(database); + throw error; + } +}; + +const readTransaction = (database: ManagedSqliteDatabase, run: () => A): A => { + database.exec("BEGIN"); + try { + const result = run(); + database.exec("COMMIT"); + return result; + } catch (error: unknown) { + rollbackPreservingCause(database); throw error; } }; @@ -366,13 +422,24 @@ const replacePorts = ( database: ManagedSqliteDatabase, stackId: string, ports: ReadonlyArray, + lifecycle: ManagedStackLifecycle, ): void => { - for (const assignment of ports) { - const owner = database - .prepare("SELECT stack_id FROM ports WHERE port = ? AND stack_id != ?") - .get([assignment.port, stackId]); - if (owner !== undefined) { - throw new ManagedPortReservationError(assignment.port, getString(owner, "stack_id")); + validateManagedPortAssignments(stackId, ports); + if (lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping") { + for (const assignment of ports) { + const owner = database + .prepare( + `SELECT ports.stack_id + FROM ports + JOIN stacks ON stacks.id = ports.stack_id + WHERE ports.port = ? AND ports.stack_id != ? + AND stacks.status != 'tombstoned' + AND stacks.lifecycle IN ('starting', 'running', 'stopping')`, + ) + .get([assignment.port, stackId]); + if (owner !== undefined) { + throw new ManagedPortReservationError(assignment.port, getString(owner, "stack_id")); + } } } database.prepare("DELETE FROM ports WHERE stack_id = ?").run([stackId]); @@ -445,7 +512,12 @@ const insertConfiguration = ( input.now, input.now, ]); - replacePorts(database, input.stackId, input.configuration.ports ?? []); + replacePorts( + database, + input.stackId, + input.configuration.ports ?? [], + input.configuration.lifecycle ?? "stopped", + ); }; export const createSqliteManagedStackRepository = ( @@ -486,9 +558,9 @@ export const createSqliteManagedStackRepository = ( getString(contextRow, "checkout_id") !== input.identity.checkoutId ) { throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - getString(contextRow, "checkout_id"), input.identity.contextId, + getString(contextRow, "checkout_id"), + input.identity.checkoutId, ); } database @@ -520,9 +592,9 @@ export const createSqliteManagedStackRepository = ( getString(pathLocation, "checkout_id") !== input.identity.checkoutId ) { throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - getString(pathLocation, "canonical_path"), input.canonicalPath, + getString(pathLocation, "checkout_id"), + input.identity.checkoutId, ); } if (checkoutLocation === undefined) { @@ -594,25 +666,31 @@ export const createSqliteManagedStackRepository = ( }); }, getStack(stackId) { - return getStack(database, stackId); + return readTransaction(database, () => getStack(database, stackId)); }, getStackByIdentity(checkoutId, contextId, stackName) { - const row = database - .prepare( - `SELECT * FROM stacks - WHERE checkout_id = ? AND context_id = ? AND name = ? AND status != 'tombstoned'`, - ) - .get([checkoutId, contextId, stackName]); - return row === undefined ? undefined : decodeStack(database, row); + return readTransaction(database, () => { + const row = database + .prepare( + `SELECT * FROM stacks + WHERE checkout_id = ? AND context_id = ? AND name = ? AND status != 'tombstoned'`, + ) + .get([checkoutId, contextId, stackName]); + return row === undefined ? undefined : decodeStack(database, row); + }); }, listStacks(options) { - const rows = - options?.includeTombstoned === true - ? database.prepare("SELECT * FROM stacks ORDER BY created_at, id").all() - : database - .prepare("SELECT * FROM stacks WHERE status != 'tombstoned' ORDER BY created_at, id") - .all(); - return rows.map((row) => decodeStack(database, row)); + return readTransaction(database, () => { + const rows = + options?.includeTombstoned === true + ? database.prepare("SELECT * FROM stacks ORDER BY created_at, id").all() + : database + .prepare( + "SELECT * FROM stacks WHERE status != 'tombstoned' ORDER BY created_at, id", + ) + .all(); + return rows.map((row) => decodeStack(database, row)); + }); }, claimOperation(input) { return claimOperation(database, input); @@ -640,6 +718,7 @@ export const createSqliteManagedStackRepository = ( const runtimeMetadata = input.runtimeMetadata ?? current.runtimeMetadata; const configFingerprint = input.configFingerprint ?? current.configFingerprint; const credentialsReference = input.credentialsReference ?? current.credentialsReference; + const ports = reconcileManagedPortAssignments(current, input.ports); database .prepare( `UPDATE stacks SET @@ -659,7 +738,7 @@ export const createSqliteManagedStackRepository = ( input.now, input.stackId, ]); - replacePorts(database, input.stackId, input.ports ?? current.ports); + replacePorts(database, input.stackId, ports, lifecycle); return requireStack(database, input.stackId); }); }, @@ -680,8 +759,19 @@ export const createSqliteManagedStackRepository = ( reconcileOperation(stackId, operationToken, lifecycle, now) { return transaction(database, () => { requireOwnedOperation(database, stackId, operationToken); + const current = requireStack(database, stackId); + if (current.status === "pending" && lifecycle === "stopped") { + database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); + return undefined; + } + replacePorts(database, stackId, current.ports, lifecycle); database - .prepare("UPDATE stacks SET lifecycle = ?, updated_at = ? WHERE id = ?") + .prepare( + `UPDATE stacks SET + status = CASE WHEN status = 'pending' THEN 'active' ELSE status END, + lifecycle = ?, updated_at = ? + WHERE id = ?`, + ) .run([lifecycle, now, stackId]); database .prepare( From a08817cd1fca109d11d1bc3b13e60703afd65e5a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 15:23:10 +0200 Subject: [PATCH 03/18] fix(stack): make managed recovery race-safe --- packages/stack/README.md | 3 +- packages/stack/docs/architecture.md | 26 +- packages/stack/package.json | 3 +- packages/stack/src/managed-paths.unit.test.ts | 10 + .../src/managed-service.integration.test.ts | 336 +++++++++++++++++- packages/stack/src/managed/model.ts | 6 +- packages/stack/src/managed/paths.ts | 2 +- packages/stack/src/managed/repository.ts | 64 ++-- packages/stack/src/managed/service.ts | 126 ++++++- packages/stack/src/managed/sqlite.ts | 12 +- 10 files changed, 505 insertions(+), 83 deletions(-) diff --git a/packages/stack/README.md b/packages/stack/README.md index 718979e656..3d204dc66b 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -62,7 +62,8 @@ only its three identity UUIDs in `.supabase/identity.json`; mutable state, logs, ports, and lifecycle ownership live under the user-level managed state root. Callers can inject an in-memory repository or an isolated state root for tests. Stopped stacks keep sticky port assignments without holding a host-wide lease; exact configuration takes precedence when a stopped -stack is updated, and active stacks reject port drift until stopped. +or failed stack is updated. A stack may change port numbers as part of one transition out of a +port-occupying lifecycle; intent-only updates never count as runtime port drift. ### With explicit config diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index ae1ebe631e..723cc0beb6 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -281,6 +281,8 @@ Here, **managed state** means the centralized registry API exposed from `managed-stack.ts`, which remains part of the legacy Effect daemon surface. The registry API uses Promises because its consumers perform short filesystem and SQLite coordination around the Promise-oriented `createStack()` boundary; the runtime lifecycle beneath it remains Effect-based. +Its errors are ordinary `Error` subclasses with stable `code` fields so Node and Bun callers can +branch on failures without requiring an Effect runtime at this persistence boundary. The managed surface owns a versioned SQLite registry with separate records for projects, checkouts, checkout locations, development contexts, stacks, port reservations, and operations. @@ -299,7 +301,9 @@ For an ordinary non-Git folder, the first mutating managed operation atomically No mutable runtime state or credential value is stored in that marker. Read-only discovery does not create it. The registry stores only an opaque credential reference, never resolved plaintext -credentials. +credentials. Discovery returns the marker identity even when it has no stack records, but reports +`registered: false` until at least one stack exists for the marker's complete project, checkout, +and context identity. The managed state root is explicitly injectable. Otherwise it resolves from `SUPABASE_HOME` or the platform application-state directory. Every physical stack path is keyed only by its opaque @@ -307,7 +311,7 @@ stack UUID: ```text / - registry-v1.sqlite3 + registry-v2.sqlite3 stacks// data/ logs/ @@ -320,17 +324,29 @@ Concurrent callers resolve the published record rather than creating aliases. Re retains claims whose owner process is still alive. Once an owner is gone, runtime inspection either publishes a running pending stack or aborts a stopped pending stack so the same identity can retry. Ownership races are isolated per operation so one completed claim does not stop the recovery pass. +PID liveness is deliberately conservative and assumes the managed root stays within one host PID +namespace. Because a PID is not a permanent process identity, callers can request forced recovery +after trustworthy runtime inspection; this is also the required integration path for a state root +shared across PID namespaces. Forced recovery bypasses only the PID gate, never runtime inspection. +Recovery results distinguish live/unknown owners, concurrent skips, reconciliation failures, and +post-abort data-reclamation failures. A reconciliation failure marks the stack lifecycle `failed` +before best-effort release of the abandoned claim, preserving the requirement for an explicit stop +path before deletion. Port assignments are sticky metadata, while port ownership is a lifecycle lease. Stopped stacks retain their assigned numbers without blocking other stopped stacks. Entering `starting`, `running`, or `stopping` claims those ports host-wide; a collision fails without relocating a sticky automatic assignment. On a stopped stack, exact configuration replaces persisted automatic -state, while an automatic request reuses the current number and changes only its intent. Running -port changes are reported as drift instead of overwriting the active assignment. +state, while an automatic request reuses the current number and changes only its intent. Failed +stacks follow the same non-occupying rules. Intent-only changes are accepted, and a lifecycle update +can release a lease and change ports atomically; port-number drift is rejected only while a stack +continues to occupy its ports. Explicit deletion re-reads lifecycle after claiming the operation, safely stops when needed, tombstones, and removes only the UUID-derived selected stack root. Repeating deletion retries any -leftover tombstoned data reclamation. Prune removes checkout location metadata only. Runtime +leftover tombstoned data reclamation. Once tombstoned, unsafe or failed filesystem cleanup is +reported as retained data rather than making future deletion non-idempotent. Prune removes checkout +location metadata only. Runtime qualification, legacy bootstrap selection, and credential resolution remain callers of this persistence boundary and are composed by later CLI slices. diff --git a/packages/stack/package.json b/packages/stack/package.json index b0706f7e09..fbc048c6aa 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -60,8 +60,7 @@ "oxlint-tsgolint" ], "ignoreBinaries": [ - "nx", - "ps" + "nx" ] } } diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index a08eccb40b..9feb6899d4 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { assertManagedUuid } from "./managed/ids.ts"; import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./managed/model.ts"; import { assertManagedStackRoot, @@ -7,6 +8,15 @@ import { } from "./managed/paths.ts"; describe("managed paths", () => { + it.each([ + ["empty", ""], + ["non-hex", "018f8b4g-8e5c-7e32-a956-6f297fd05a2d"], + ["unsupported version", "018f8b4e-8e5c-0e32-a956-6f297fd05a2d"], + ["invalid variant", "018f8b4e-8e5c-7e32-7956-6f297fd05a2d"], + ])("rejects %s managed UUIDs", (_case, value) => { + expect(() => assertManagedUuid(value, "test id")).toThrow(InvalidManagedIdentityError); + }); + it("isolates managed records beneath SUPABASE_HOME", () => { expect( resolveManagedStateRoot({ diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index f1a574bcdd..679b3a7bd5 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -15,14 +15,20 @@ import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { managedStackContractFixtures } from "./managed-stack-contract.ts"; import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; -import { managedStackPaths, ordinaryWorkspaceIdentityPath } from "./managed/paths.ts"; +import { + managedRegistryPath, + managedStackPaths, + ordinaryWorkspaceIdentityPath, +} from "./managed/paths.ts"; import { DuplicateManagedIdentityError, InvalidManagedIdentityError, ManagedOperationInProgressError, + ManagedOperationOwnershipError, ManagedPortReservationError, ManagedRunningStackPortChangeError, ManagedStackInitializationError, + ManagedStackNotFoundError, ManagedStackPublicationTimeoutError, UnsafeManagedStackPathError, UnsupportedManagedRegistryVersionError, @@ -89,7 +95,7 @@ const makePersistentService = ( overrides: ServiceOverrides = {}, ): ManagedStackService => { const stateRoot = join(root, "managed"); - const databasePath = join(stateRoot, "registry-v1.sqlite3"); + const databasePath = managedRegistryPath(stateRoot); return makeManagedStackService({ repository: openBunSqliteManagedStackRepository(databasePath), stateRoot, @@ -130,7 +136,7 @@ const invalidStackNameCases = managedStackContractFixtures const prepareAbandonedStack = async ( service: ManagedStackService, workspace: string, - ownerPid: number, + ownerPid?: number, ) => { const identity = (await ensureOrdinaryWorkspaceIdentity(workspace)).identity; const stackId = crypto.randomUUID(); @@ -167,6 +173,47 @@ describe("ordinary-folder managed stack contract", () => { expect(service.repository.listCheckoutLocations()).toEqual([]); }); + it("reports an existing identity without stacks as not yet registered", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makeInMemoryService(root); + const marker = await ensureOrdinaryWorkspaceIdentity(workspace); + + const result = await service.inspectOrdinaryWorkspace(workspace); + + expect(result).toEqual({ registered: false, identity: marker.identity, stacks: [] }); + }); + + it("filters inspected stacks by the complete project, checkout, and context identity", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let foreignContextStack: ReturnType; + const filteringRepository: ManagedStackRepository = { + ...repository, + listStacks(options) { + const stacks = repository.listStacks(options); + return foreignContextStack === undefined ? stacks : [...stacks, foreignContextStack]; + }, + }; + const service = makeManagedStackService({ + repository: filteringRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + foreignContextStack = { + ...created.stack, + id: crypto.randomUUID(), + contextId: crypto.randomUUID(), + }; + + const result = await service.inspectOrdinaryWorkspace(join(root, "workspace")); + + expect(result.registered).toBe(true); + expect(result.stacks).toEqual([created.stack]); + }); + it("fails safely on an unknown newer workspace identity marker", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); @@ -279,7 +326,7 @@ describe("ordinary-folder managed stack contract", () => { expect(reopened.listStacks()).toHaveLength(1); reopened.close(); - const registry = new Database(join(root, "managed", "registry-v1.sqlite3")); + const registry = new Database(managedRegistryPath(join(root, "managed"))); const columns = registry.query("PRAGMA table_info(stacks)").all(); const columnNames = columns.map((column) => typeof column === "object" && column !== null ? Reflect.get(column, "name") : undefined, @@ -367,7 +414,6 @@ describe("ordinary-folder managed stack contract", () => { }); it("rejects a copied ordinary-folder identity claim", async () => { - fixture("identity.copied-checkout-reports-duplicate-claim"); const root = makeRoot(); const firstWorkspace = makeWorkspace(root, "first"); const secondWorkspace = makeWorkspace(root, "copy"); @@ -544,7 +590,7 @@ describe("managed repository and lifecycle", () => { }); expect(unknown.recovered).toEqual([]); expect(unknown.abortedStackIds).toEqual([]); - expect(unknown.retained).toEqual([claimed.operation]); + expect(unknown.retained).toEqual([{ operation: claimed.operation, reason: "runtime-unknown" }]); expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("starting"); const reconciled = await service.reconcileAbandonedOperations({ @@ -618,10 +664,72 @@ describe("managed repository and lifecycle", () => { }); expect(inspected).toBe(false); - expect(reconciled.retained).toEqual([pending.operation]); + expect(reconciled.retained).toEqual([{ operation: pending.operation, reason: "owner-alive" }]); expect(service.inspectStack(pending.stack.id)?.status).toBe("pending"); }); + it("force-recovers an operation when a stale or reused PID still appears alive", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root, { isProcessAlive: () => true }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_652); + + const retained = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + expect(retained.retained).toEqual([{ operation: pending.operation, reason: "owner-alive" }]); + + const forced = await service.reconcileAbandonedOperations({ + force: true, + inspectRuntime: async () => "stopped", + }); + expect(forced.abortedStackIds).toEqual([pending.stack.id]); + expect(forced.retained).toEqual([]); + expect(service.listStacks()).toEqual([]); + }); + + it("reconciles repository operations that have no owner PID", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root, { isProcessAlive: () => true }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root)); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + expect(reconciled.abortedStackIds).toEqual([pending.stack.id]); + expect(reconciled.retained).toEqual([]); + }); + + it("does not reclaim data when another recovery pass adopts the pending stack", async () => { + const root = makeRoot(); + const service = makePersistentService(root, { isProcessAlive: () => false }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_653); + const dataFile = join(pending.stack.paths.data, "database"); + writeFileSync(dataFile, "live data"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async (stack, operation) => { + service.repository.reconcileOperation( + stack.id, + operation.token, + "running", + "2026-08-11T00:00:01.000Z", + ); + return "stopped"; + }, + }); + + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.recovered).toEqual([]); + expect(reconciled.skippedOperationIds).toEqual([pending.operation.token]); + expect(service.inspectStack(pending.stack.id)).toMatchObject({ + status: "active", + lifecycle: "running", + }); + expect(readFileSync(dataFile, "utf8")).toBe("live data"); + service.close(); + }); + it("continues recovery when an owner finishes one operation during inspection", async () => { const root = makeRoot(); const service = makeInMemoryService(root, { isProcessAlive: () => false }); @@ -665,8 +773,63 @@ describe("managed repository and lifecycle", () => { expect(reconciled.retained).toEqual([]); expect(reconciled.recovered.map((stack) => stack.id)).toEqual([second.stack.id]); + expect(reconciled.skippedOperationIds).toEqual([firstOperation.operation.token]); }); + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`releases a failed runtime adoption operation with ${adapter}`, async () => { + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? makeInMemoryService(root, overrides) + : makePersistentService(root, overrides); + await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "owner"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 55_410, intent: "exact" }], + }, + }); + const blocked = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "blocked"), + configuration: { + ports: [{ key: "api.port", port: 55_410, intent: "exact" }], + }, + }); + const operation = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: blocked.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:00.000Z", + }); + if (!operation.acquired) { + throw new Error("Expected the abandoned start operation to be claimed"); + } + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(reconciled.failures).toHaveLength(1); + expect(reconciled.failures[0]).toMatchObject({ + operation: operation.operation, + phase: "reconciliation", + operationReleased: true, + error: expect.any(ManagedPortReservationError), + }); + expect(service.repository.listActiveOperations()).toEqual([]); + expect(service.inspectStack(blocked.stack.id)?.lifecycle).toBe("failed"); + await expect( + service.deleteStack(blocked.stack.id, { stop: async () => {} }), + ).resolves.toMatchObject({ + outcome: "delete", + }); + service.close(); + }); + } + it("applies exact stopped-stack ports and makes removed exact keys sticky", async () => { const changedFixtureId = "ports.config-change-on-stopped-stack-applies"; const removedFixtureId = "ports.removing-exact-key-keeps-current-port-sticky"; @@ -732,12 +895,68 @@ describe("managed repository and lifecycle", () => { service.close(); }); + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`allows failed-stack recovery and intent-only updates with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + const failed = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "failed"), + configuration: { + lifecycle: "failed", + ports: [{ key: "api.port", port: 55_401, intent: "exact" }], + }, + }); + + const restarted = await service.updateStack(failed.stack.id, { + lifecycle: "starting", + ports: [{ key: "api.port", port: 55_402, intent: "exact" }], + }); + expect(restarted).toMatchObject({ + lifecycle: "starting", + ports: [{ key: "api.port", port: 55_402, intent: "exact" }], + }); + + const running = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "running"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 55_403, intent: "automatic" }], + }, + }); + const pinned = await service.updateStack(running.stack.id, { + ports: [{ key: "api.port", port: 55_403, intent: "exact" }], + }); + expect(pinned.ports).toEqual([{ key: "api.port", port: 55_403, intent: "exact" }]); + + const stoppedAndChanged = await service.updateStack(running.stack.id, { + lifecycle: "stopped", + ports: [{ key: "api.port", port: 55_404, intent: "exact" }], + }); + expect(stoppedAndChanged).toMatchObject({ + lifecycle: "stopped", + ports: [{ key: "api.port", port: 55_404, intent: "exact" }], + }); + service.close(); + }); + } + it("keeps stopped sticky assignments soft and claims them only while starting", async () => { - fixture("ports.sticky-ports-reuse-on-return"); - fixture("ports.later-sticky-port-collision-fails"); + const stickyContract = fixture("ports.sticky-ports-reuse-on-return"); + const collisionContract = fixture("ports.later-sticky-port-collision-fails"); + const stickyAssignment = portAssignmentFacts(stickyContract.id)[0]; + const collisionAssignment = portAssignmentFacts(collisionContract.id)[0]; + if (stickyAssignment === undefined || collisionAssignment === undefined) { + throw new Error("Sticky-port fixtures must provide persisted assignments"); + } + expect(stickyAssignment.port).toBe(collisionAssignment.port); const root = makeRoot(); const service = makePersistentService(root); - const assignment = { key: "api.port", port: 55_421, intent: "automatic" as const }; + const assignment = { + key: stickyAssignment.key, + port: stickyAssignment.port, + intent: stickyAssignment.intent, + }; const first = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "first"), configuration: { ports: [assignment] }, @@ -751,10 +970,12 @@ describe("managed repository and lifecycle", () => { await expect( service.updateStack(second.stack.id, { lifecycle: "starting" }), ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect(collisionContract.expected.outcome).toBe("error"); await service.updateStack(first.stack.id, { lifecycle: "stopped" }); const startedSecond = await service.updateStack(second.stack.id, { lifecycle: "starting" }); expect(startedSecond.ports).toEqual([assignment]); + expect(stickyContract.expected.outcome).toBe("reuse"); service.close(); }); @@ -801,6 +1022,42 @@ describe("managed repository and lifecycle", () => { expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("stopped"); }); + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`reports missing stacks and operation ownership mismatches with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + + await expect( + service.updateStack(crypto.randomUUID(), { lifecycle: "stopped" }), + ).rejects.toBeInstanceOf(ManagedStackNotFoundError); + + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "update", + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + throw new Error("Expected the update operation to be claimed"); + } + + expect(() => + service.repository.finishOperation( + created.stack.id, + crypto.randomUUID(), + "completed", + "2026-08-11T00:00:01.000Z", + ), + ).toThrow(ManagedOperationOwnershipError); + service.close(); + }); + } + it("re-reads lifecycle after claiming delete before deciding whether to stop", async () => { const root = makeRoot(); const repository = createInMemoryManagedStackRepository(); @@ -871,15 +1128,17 @@ describe("managed repository and lifecycle", () => { const repeated = await service.deleteStack(created.stack.id); expect(deleted.outcome).toBe("delete"); + expect(deleted.dataReclamation).toEqual({ outcome: "removed" }); expect(stoppedStackId).toBe(created.stack.id); expect(existsSync(created.stack.paths.root)).toBe(false); expect(repeated.outcome).toBe(contract.expected.outcome); + expect(repeated.dataReclamation).toEqual({ outcome: "removed" }); expect(service.listStacks()).toEqual([]); expect(service.listStacks({ includeTombstoned: true })).toHaveLength(1); service.close(); }); - it("refuses tombstone reclamation outside the UUID-derived managed root", async () => { + it("reports unsafe tombstone data as retained without deleting it", async () => { const root = makeRoot(); const repository = createInMemoryManagedStackRepository(); let forgePath = false; @@ -914,9 +1173,15 @@ describe("managed repository and lifecycle", () => { await service.deleteStack(created.stack.id); forgePath = true; - await expect(service.deleteStack(created.stack.id)).rejects.toBeInstanceOf( - UnsafeManagedStackPathError, - ); + const repeated = await service.deleteStack(created.stack.id); + + expect(repeated).toMatchObject({ + outcome: "no-op", + dataReclamation: { + outcome: "retained", + error: expect.any(UnsafeManagedStackPathError), + }, + }); expect(readFileSync(join(outsideRoot, "preserve"), "utf8")).toBe("safe"); }); @@ -953,11 +1218,31 @@ describe("managed repository and lifecycle", () => { import { makeManagedStackService } from ${JSON.stringify(serviceUrl)}; const stateRoot = ${JSON.stringify(stateRoot)}; const workspacePath = ${JSON.stringify(workspace)}; - const databasePath = ${JSON.stringify(join(stateRoot, "registry-v1.sqlite3"))}; + const databasePath = ${JSON.stringify(managedRegistryPath(stateRoot))}; const firstRepository = openNodeSqliteManagedStackRepository(databasePath); assert.equal(firstRepository.getStack(randomUUID()), undefined); const firstService = makeManagedStackService({ repository: firstRepository, stateRoot }); - const first = await firstService.provisionOrdinaryStack({ workspacePath }); + const first = await firstService.provisionOrdinaryStack({ + workspacePath, + configuration: { + ports: [{ key: "api.port", port: 55431, intent: "exact" }], + }, + }); + const starting = await firstService.updateStack(first.stack.id, { lifecycle: "starting" }); + assert.equal(starting.ports[0]?.port, 55431); + await firstService.updateStack(first.stack.id, { lifecycle: "stopped" }); + const abandoned = firstRepository.claimOperation({ + token: randomUUID(), + stackId: first.stack.id, + kind: "start", + now: new Date().toISOString(), + }); + assert.equal(abandoned.acquired, true); + const recovery = await firstService.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + assert.equal(recovery.recovered.length, 1); + assert.equal(recovery.failures.length, 0); firstService.close(); const secondRepository = openNodeSqliteManagedStackRepository(databasePath); const secondService = makeManagedStackService({ repository: secondRepository, stateRoot }); @@ -965,6 +1250,11 @@ describe("managed repository and lifecycle", () => { assert.equal(first.outcome, "create"); assert.equal(second.outcome, "reuse"); assert.equal(second.stack.id, first.stack.id); + const deleted = await secondService.deleteStack(second.stack.id); + const repeated = await secondService.deleteStack(second.stack.id); + assert.equal(deleted.outcome, "delete"); + assert.equal(deleted.dataReclamation.outcome, "removed"); + assert.equal(repeated.outcome, "no-op"); secondService.close(); `; const command = [ @@ -988,7 +1278,7 @@ describe("managed repository and lifecycle", () => { it("initializes one fresh registry safely across concurrent Bun processes", async () => { const root = makeRoot(); - const databasePath = join(root, "cold", "registry-v1.sqlite3"); + const databasePath = managedRegistryPath(join(root, "cold")); const adapterUrl = pathToFileURL(join(process.cwd(), "src/managed/sqlite-bun.ts")).href; const source = ` import { openBunSqliteManagedStackRepository } from ${JSON.stringify(adapterUrl)}; @@ -1024,4 +1314,16 @@ describe("managed repository and lifecycle", () => { UnsupportedManagedRegistryVersionError, ); }); + + it("fails clearly instead of opening the obsolete development schema", () => { + const root = makeRoot(); + const databasePath = join(root, "obsolete.sqlite3"); + const database = new Database(databasePath, { create: true }); + database.exec("PRAGMA user_version = 1"); + database.close(); + + expect(() => openBunSqliteManagedStackRepository(databasePath)).toThrow( + UnsupportedManagedRegistryVersionError, + ); + }); }); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index d365b8e743..b5f05741be 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -1,4 +1,4 @@ -export const MANAGED_REGISTRY_SCHEMA_VERSION = 1; +export const MANAGED_REGISTRY_SCHEMA_VERSION = 2; export const ORDINARY_WORKSPACE_IDENTITY_VERSION = 1; export const DEFAULT_MANAGED_STACK_NAME = "default"; @@ -113,7 +113,7 @@ export class UnsupportedManagedRegistryVersionError extends ManagedStackError { readonly found: number, readonly supported: number, ) { - super(`Managed registry version ${found} is newer than supported version ${supported}`); + super(`Managed registry version ${found} is unsupported; expected version ${supported}`); this.name = "UnsupportedManagedRegistryVersionError"; } } @@ -179,7 +179,7 @@ export class ManagedRunningStackPortChangeError extends ManagedStackError { readonly code = "MANAGED_RUNNING_STACK_PORT_CHANGE"; constructor(readonly stackId: string) { - super(`Managed stack ${stackId} must be stopped before changing its persisted ports`); + super(`Managed stack ${stackId} cannot change ports while it continues to occupy them`); this.name = "ManagedRunningStackPortChangeError"; } } diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index 3cb5dace7c..74d3e81fbf 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -39,7 +39,7 @@ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): }; export const managedRegistryPath = (stateRoot: string): string => - join(stateRoot, "registry-v1.sqlite3"); + join(stateRoot, "registry-v2.sqlite3"); export const managedStackPaths = (stateRoot: string, stackId: string): ManagedStackPaths => { assertManagedUuid(stackId, "stackId"); diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index f1e7bac802..ee62f305de 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -108,7 +108,10 @@ const stackIdentityKey = (checkoutId: string, contextId: string, stackName: stri const copy = (value: A): A => structuredClone(value); -const portsEqual = ( +export const managedStackOccupiesPorts = (lifecycle: ManagedStackLifecycle): boolean => + lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping"; + +const portNumbersEqual = ( left: ReadonlyArray, right: ReadonlyArray, ): boolean => { @@ -118,11 +121,7 @@ const portsEqual = ( const byKey = new Map(right.map((assignment) => [assignment.key, assignment])); return left.every((assignment) => { const candidate = byKey.get(assignment.key); - return ( - candidate !== undefined && - assignment.port === candidate.port && - assignment.intent === candidate.intent - ); + return candidate !== undefined && assignment.port === candidate.port; }); }; @@ -150,42 +149,48 @@ export const validateManagedPortAssignments = ( export const reconcileManagedPortAssignments = ( stack: ManagedStackRecord, requested: ReadonlyArray | undefined, + targetLifecycle: ManagedStackLifecycle = stack.lifecycle, ): ReadonlyArray => { if (requested === undefined) { return stack.ports; } validateManagedPortAssignments(stack.id, requested); - if (stack.lifecycle !== "stopped") { - if (!portsEqual(stack.ports, requested)) { - throw new ManagedRunningStackPortChangeError(stack.id); - } - return stack.ports; - } const persisted = new Map(stack.ports.map((assignment) => [assignment.key, assignment])); - return requested.map((assignment) => { + const reconciled = requested.map((assignment) => { const current = persisted.get(assignment.key); return assignment.intent === "automatic" && current !== undefined ? { ...assignment, port: current.port } : assignment; }); + if ( + managedStackOccupiesPorts(stack.lifecycle) && + managedStackOccupiesPorts(targetLifecycle) && + !portNumbersEqual(stack.ports, reconciled) + ) { + throw new ManagedRunningStackPortChangeError(stack.id); + } + return reconciled; }; const applyConfiguration = ( stack: ManagedStackRecord, configuration: ManagedStackConfiguration, now: string, -): ManagedStackRecord => ({ - ...stack, - lifecycle: configuration.lifecycle ?? stack.lifecycle, - runtimeRequest: configuration.runtimeRequest ?? stack.runtimeRequest, - runtime: configuration.runtime ?? stack.runtime, - ports: reconcileManagedPortAssignments(stack, configuration.ports), - serviceVersions: configuration.serviceVersions ?? stack.serviceVersions, - runtimeMetadata: configuration.runtimeMetadata ?? stack.runtimeMetadata, - configFingerprint: configuration.configFingerprint ?? stack.configFingerprint, - credentialsReference: configuration.credentialsReference ?? stack.credentialsReference, - updatedAt: now, -}); +): ManagedStackRecord => { + const lifecycle = configuration.lifecycle ?? stack.lifecycle; + return { + ...stack, + lifecycle, + runtimeRequest: configuration.runtimeRequest ?? stack.runtimeRequest, + runtime: configuration.runtime ?? stack.runtime, + ports: reconcileManagedPortAssignments(stack, configuration.ports, lifecycle), + serviceVersions: configuration.serviceVersions ?? stack.serviceVersions, + runtimeMetadata: configuration.runtimeMetadata ?? stack.runtimeMetadata, + configFingerprint: configuration.configFingerprint ?? stack.configFingerprint, + credentialsReference: configuration.credentialsReference ?? stack.credentialsReference, + updatedAt: now, + }; +}; const emptyRuntimeMetadata = (): ManagedRuntimeMetadata => ({ processIds: {}, @@ -267,15 +272,12 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = return operation; }; - const occupiesPorts = (lifecycle: ManagedStackLifecycle): boolean => - lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping"; - const transitionPortOwnership = ( current: ManagedStackRecord | undefined, next: ManagedStackRecord, ): void => { validateManagedPortAssignments(next.id, next.ports); - if (occupiesPorts(next.lifecycle)) { + if (managedStackOccupiesPorts(next.lifecycle)) { for (const assignment of next.ports) { const owner = portOwners.get(assignment.port); if (owner !== undefined && owner !== next.id) { @@ -283,14 +285,14 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = } } } - if (current !== undefined && occupiesPorts(current.lifecycle)) { + if (current !== undefined && managedStackOccupiesPorts(current.lifecycle)) { for (const assignment of current.ports) { if (portOwners.get(assignment.port) === current.id) { portOwners.delete(assignment.port); } } } - if (occupiesPorts(next.lifecycle)) { + if (managedStackOccupiesPorts(next.lifecycle)) { for (const assignment of next.ports) { const owner = portOwners.get(assignment.port); if (owner !== undefined && owner !== next.id) { diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index e003107cec..0fe1902ca2 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -61,20 +61,43 @@ export interface InspectOrdinaryWorkspaceResult { export interface DeleteManagedStackResult { readonly outcome: "delete" | "no-op"; readonly stack: ManagedStackRecord; + readonly dataReclamation: + | { readonly outcome: "removed" } + | { readonly outcome: "retained"; readonly error: unknown }; } export interface ReconcileAbandonedOperationsOptions { readonly startedBefore?: string; + readonly force?: boolean; readonly inspectRuntime: ( stack: ManagedStackRecord, operation: ManagedOperationRecord, ) => Promise<"running" | "stopped" | "unknown">; } +export interface RetainedManagedOperation { + readonly operation: ManagedOperationRecord; + readonly reason: + | "owner-alive" + | "owner-liveness-unknown" + | "runtime-inspection-failed" + | "runtime-unknown"; + readonly error?: unknown; +} + +export interface ManagedOperationRecoveryFailure { + readonly operation: ManagedOperationRecord; + readonly phase: "reconciliation" | "state-reclamation"; + readonly operationReleased: boolean; + readonly error: unknown; +} + export interface ReconcileAbandonedOperationsResult { readonly recovered: ReadonlyArray; readonly abortedStackIds: ReadonlyArray; - readonly retained: ReadonlyArray; + readonly retained: ReadonlyArray; + readonly skippedOperationIds: ReadonlyArray; + readonly failures: ReadonlyArray; } export interface ManagedStackService { @@ -149,16 +172,43 @@ export const makeManagedStackService = ( await rm(root, { force: true, recursive: true }); }; + const reclaimStackState = async ( + stack: ManagedStackRecord, + ): Promise => { + try { + await removeStackState(stack); + return { outcome: "removed" }; + } catch (error: unknown) { + return { outcome: "retained", error }; + } + }; + const finishOperationBestEffort = ( stackId: string, operationToken: string, error: unknown, - ): void => { + ): boolean => { try { options.repository.finishOperation(stackId, operationToken, "failed", now(), String(error)); + return true; } catch { // Preserve the operation's original failure when ownership changed concurrently. + return false; + } + }; + + const failRecoveryBestEffort = (operation: ManagedOperationRecord, error: unknown): boolean => { + try { + options.repository.updateStack({ + stackId: operation.stackId, + operationToken: operation.token, + lifecycle: "failed", + now: now(), + }); + } catch { + // Releasing the abandoned claim is still useful if the failed lifecycle cannot be recorded. } + return finishOperationBestEffort(operation.stackId, operation.token, error); }; const requireOperation = ( @@ -260,6 +310,12 @@ export const makeManagedStackService = ( if (prepared.operation === undefined) { throw new ManagedAbandonedOperationError(prepared.stack.id); } + if ( + prepared.operation.ownerPid === undefined || + !(await isProcessAlive(prepared.operation.ownerPid)) + ) { + throw new ManagedAbandonedOperationError(prepared.stack.id); + } const published = await awaitPublication(prepared.stack); return { outcome: "reuse", @@ -332,8 +388,11 @@ export const makeManagedStackService = ( throw new ManagedStackNotFoundError(stackId); } if (existing.status === "tombstoned") { - await removeStackState(existing); - return { outcome: "no-op", stack: existing }; + return { + outcome: "no-op", + stack: existing, + dataReclamation: await reclaimStackState(existing), + }; } const operation = requireOperation(stackId, "delete"); try { @@ -342,9 +401,9 @@ export const makeManagedStackService = ( throw new ManagedStackNotFoundError(stackId); } if (current.status === "tombstoned") { - await removeStackState(current); + const dataReclamation = await reclaimStackState(current); options.repository.finishOperation(stackId, operation.token, "completed", now()); - return { outcome: "no-op", stack: current }; + return { outcome: "no-op", stack: current, dataReclamation }; } if (current.lifecycle !== "stopped") { if (deleteOptions?.stop === undefined) { @@ -360,9 +419,9 @@ export const makeManagedStackService = ( }); } const tombstoned = options.repository.tombstoneStack(stackId, operation.token, now()); - await removeStackState(tombstoned); + const dataReclamation = await reclaimStackState(tombstoned); options.repository.finishOperation(stackId, operation.token, "completed", now()); - return { outcome: "delete", stack: tombstoned }; + return { outcome: "delete", stack: tombstoned, dataReclamation }; } catch (error: unknown) { finishOperationBestEffort(stackId, operation.token, error); throw error; @@ -371,28 +430,41 @@ export const makeManagedStackService = ( async reconcileAbandonedOperations(reconcileOptions) { const recovered: Array = []; const abortedStackIds: Array = []; - const retained: Array = []; + const retained: Array = []; + const skippedOperationIds: Array = []; + const failures: Array = []; for (const operation of options.repository.listActiveOperations( reconcileOptions.startedBefore, )) { - if (operation.ownerPid === undefined || (await isProcessAlive(operation.ownerPid))) { - retained.push(operation); - continue; + if (reconcileOptions.force !== true && operation.ownerPid !== undefined) { + try { + if (await isProcessAlive(operation.ownerPid)) { + retained.push({ operation, reason: "owner-alive" }); + continue; + } + } catch (error: unknown) { + retained.push({ operation, reason: "owner-liveness-unknown", error }); + continue; + } } try { const stack = options.repository.getStack(operation.stackId); if (stack === undefined) { + skippedOperationIds.push(operation.token); + continue; + } + let actual: "running" | "stopped" | "unknown"; + try { + actual = await reconcileOptions.inspectRuntime(stack, operation); + } catch (error: unknown) { + retained.push({ operation, reason: "runtime-inspection-failed", error }); continue; } - const actual = await reconcileOptions.inspectRuntime(stack, operation); if (actual === "unknown") { - retained.push(operation); + retained.push({ operation, reason: "runtime-unknown" }); continue; } const lifecycle: ManagedStackLifecycle = actual === "running" ? "running" : "stopped"; - if (stack.status === "pending" && lifecycle === "stopped") { - await removeStackState(stack); - } const reconciled = options.repository.reconcileOperation( stack.id, operation.token, @@ -401,6 +473,16 @@ export const makeManagedStackService = ( ); if (reconciled === undefined) { abortedStackIds.push(stack.id); + try { + await removeStackState(stack); + } catch (error: unknown) { + failures.push({ + operation, + phase: "state-reclamation", + operationReleased: true, + error, + }); + } } else { recovered.push(reconciled); } @@ -409,12 +491,18 @@ export const makeManagedStackService = ( error instanceof ManagedOperationOwnershipError || error instanceof ManagedStackNotFoundError ) { + skippedOperationIds.push(operation.token); continue; } - retained.push(operation); + failures.push({ + operation, + phase: "reconciliation", + operationReleased: failRecoveryBestEffort(operation, error), + error, + }); } } - return { recovered, abortedStackIds, retained }; + return { recovered, abortedStackIds, retained, skippedOperationIds, failures }; }, async pruneCheckoutLocations(shouldPrune) { const stale: Array = []; diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index e3016dbb68..c42d1d1de9 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -28,7 +28,11 @@ import type { PrepareOrdinaryStackResult, UpdateManagedStackInput, } from "./repository.ts"; -import { reconcileManagedPortAssignments, validateManagedPortAssignments } from "./repository.ts"; +import { + managedStackOccupiesPorts, + reconcileManagedPortAssignments, + validateManagedPortAssignments, +} from "./repository.ts"; type SqliteValue = null | number | string; @@ -207,7 +211,7 @@ const initializeSchema = (database: ManagedSqliteDatabase): void => { try { const versionRow = database.prepare("PRAGMA user_version").get(); const version = getNumber(versionRow, "user_version"); - if (version > MANAGED_REGISTRY_SCHEMA_VERSION) { + if (version !== 0 && version !== MANAGED_REGISTRY_SCHEMA_VERSION) { throw new UnsupportedManagedRegistryVersionError(version, MANAGED_REGISTRY_SCHEMA_VERSION); } if (version === MANAGED_REGISTRY_SCHEMA_VERSION) { @@ -425,7 +429,7 @@ const replacePorts = ( lifecycle: ManagedStackLifecycle, ): void => { validateManagedPortAssignments(stackId, ports); - if (lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping") { + if (managedStackOccupiesPorts(lifecycle)) { for (const assignment of ports) { const owner = database .prepare( @@ -718,7 +722,7 @@ export const createSqliteManagedStackRepository = ( const runtimeMetadata = input.runtimeMetadata ?? current.runtimeMetadata; const configFingerprint = input.configFingerprint ?? current.configFingerprint; const credentialsReference = input.credentialsReference ?? current.credentialsReference; - const ports = reconcileManagedPortAssignments(current, input.ports); + const ports = reconcileManagedPortAssignments(current, input.ports, lifecycle); database .prepare( `UPDATE stacks SET From aa343ce5ecc9fe1b71466b4db4c2e006de23805b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 15:50:27 +0200 Subject: [PATCH 04/18] fix(stack): scope managed recovery --- packages/stack/docs/architecture.md | 25 +- packages/stack/src/managed-paths.unit.test.ts | 1 + .../src/managed-service.integration.test.ts | 290 +++++++++++++++++- packages/stack/src/managed/service.ts | 56 +++- 4 files changed, 348 insertions(+), 24 deletions(-) diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 723cc0beb6..355f10a381 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -318,6 +318,10 @@ stack UUID: runtime/ ``` +Schema v2 intentionally has no migration path for this unreleased POC. Before first use of v2, +developers with `registry-v1.sqlite3` must remove the old managed state root, including its shared +`stacks/` directory; v1 and v2 state must not be kept side by side. + Stack publication and operation claims are transactional. A new stack remains `pending` while its directories and caller-supplied initialization are validated, then becomes `active` atomically. Concurrent callers resolve the published record rather than creating aliases. Recovery first @@ -327,11 +331,13 @@ Ownership races are isolated per operation so one completed claim does not stop PID liveness is deliberately conservative and assumes the managed root stays within one host PID namespace. Because a PID is not a permanent process identity, callers can request forced recovery after trustworthy runtime inspection; this is also the required integration path for a state root -shared across PID namespaces. Forced recovery bypasses only the PID gate, never runtime inspection. -Recovery results distinguish live/unknown owners, concurrent skips, reconciliation failures, and -post-abort data-reclamation failures. A reconciliation failure marks the stack lifecycle `failed` -before best-effort release of the abandoned claim, preserving the requirement for an explicit stop -path before deletion. +shared across PID namespaces. Forced recovery requires an exact stack ID and operation token, +processes only that claim, and bypasses only its PID gate—never runtime inspection. Recovery results +distinguish live owners, unknown or failed liveness/runtime inspection, concurrent skips, +reconciliation failures, and post-abort data-reclamation failures. A failed reconciliation of an +active stack marks its lifecycle `failed` before best-effort claim release, preserving the +requirement for an explicit stop path before deletion. A failed pending-stack adoption retains its +claim so a later pass can retry without losing potentially live unpublished data. Port assignments are sticky metadata, while port ownership is a lifecycle lease. Stopped stacks retain their assigned numbers without blocking other stopped stacks. Entering `starting`, @@ -346,9 +352,12 @@ Explicit deletion re-reads lifecycle after claiming the operation, safely stops tombstones, and removes only the UUID-derived selected stack root. Repeating deletion retries any leftover tombstoned data reclamation. Once tombstoned, unsafe or failed filesystem cleanup is reported as retained data rather than making future deletion non-idempotent. Prune removes checkout -location metadata only. Runtime -qualification, legacy bootstrap selection, and credential resolution remain callers of this -persistence boundary and are composed by later CLI slices. +location metadata only. The delete outcome describes the registry tombstone, not guaranteed disk +reclamation: callers must inspect `dataReclamation`, surface retained errors, and arrange a later +retry. Lifecycle transitions likewise trust the caller to stop the real runtime before declaring a +port-occupying stack stopped and releasing its lease. Runtime qualification, legacy bootstrap +selection, and credential resolution remain outside this persistence boundary and are composed by +later CLI slices. ## Legacy daemon paths diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index 9feb6899d4..9f379ff290 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -10,6 +10,7 @@ import { describe("managed paths", () => { it.each([ ["empty", ""], + ["wrong-length", "018f8b4e-8e5c-7e32-a956-6f297fd05a2"], ["non-hex", "018f8b4g-8e5c-7e32-a956-6f297fd05a2d"], ["unsupported version", "018f8b4e-8e5c-0e32-a956-6f297fd05a2d"], ["invalid variant", "018f8b4e-8e5c-7e32-7956-6f297fd05a2d"], diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 679b3a7bd5..168c8cfcbc 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -32,6 +32,7 @@ import { ManagedStackPublicationTimeoutError, UnsafeManagedStackPathError, UnsupportedManagedRegistryVersionError, + type ManagedStackConfiguration, } from "./managed/model.ts"; import { createInMemoryManagedStackRepository, @@ -137,6 +138,7 @@ const prepareAbandonedStack = async ( service: ManagedStackService, workspace: string, ownerPid?: number, + configuration: ManagedStackConfiguration = {}, ) => { const identity = (await ensureOrdinaryWorkspaceIdentity(workspace)).identity; const stackId = crypto.randomUUID(); @@ -150,7 +152,7 @@ const prepareAbandonedStack = async ( operationToken: crypto.randomUUID(), ownerPid, now: "2026-08-11T00:00:00.000Z", - configuration: {}, + configuration, }); if (prepared.outcome !== "create") { throw new Error("Expected an abandoned pending stack"); @@ -679,7 +681,10 @@ describe("managed repository and lifecycle", () => { expect(retained.retained).toEqual([{ operation: pending.operation, reason: "owner-alive" }]); const forced = await service.reconcileAbandonedOperations({ - force: true, + force: { + stackId: pending.stack.id, + operationToken: pending.operation.token, + }, inspectRuntime: async () => "stopped", }); expect(forced.abortedStackIds).toEqual([pending.stack.id]); @@ -687,6 +692,72 @@ describe("managed repository and lifecycle", () => { expect(service.listStacks()).toEqual([]); }); + it("scopes forced recovery to one exact operation", async () => { + const root = makeRoot(); + const service = makeInMemoryService(root, { isProcessAlive: () => true }); + const pending = await Promise.all( + ["first", "target", "third"].map((name, index) => + prepareAbandonedStack(service, makeWorkspace(root, name), 987_660 + index), + ), + ); + const target = pending[1]; + if (target === undefined) { + throw new Error("Expected a target operation"); + } + const inspected: Array = []; + + const staleTarget = await service.reconcileAbandonedOperations({ + force: { + stackId: target.stack.id, + operationToken: crypto.randomUUID(), + }, + inspectRuntime: async (stack) => { + inspected.push(stack.id); + return "stopped"; + }, + }); + + expect(staleTarget.abortedStackIds).toEqual([]); + expect(inspected).toEqual([]); + expect(service.repository.listActiveOperations()).toHaveLength(3); + + const forced = await service.reconcileAbandonedOperations({ + force: { + stackId: target.stack.id, + operationToken: target.operation.token, + }, + inspectRuntime: async (stack) => { + inspected.push(stack.id); + return "stopped"; + }, + }); + + expect(inspected).toEqual([target.stack.id]); + expect(forced.abortedStackIds).toEqual([target.stack.id]); + expect( + service.repository + .listActiveOperations() + .map(({ token }) => token) + .sort(), + ).toEqual( + pending + .filter(({ stack }) => stack.id !== target.stack.id) + .map(({ operation }) => operation.token) + .sort(), + ); + expect( + service + .listStacks() + .map(({ id }) => id) + .sort(), + ).toEqual( + pending + .filter(({ stack }) => stack.id !== target.stack.id) + .map(({ stack }) => stack.id) + .sort(), + ); + }); + it("reconciles repository operations that have no owner PID", async () => { const root = makeRoot(); const service = makeInMemoryService(root, { isProcessAlive: () => true }); @@ -730,6 +801,144 @@ describe("managed repository and lifecycle", () => { service.close(); }); + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`keeps provisioned data when recovery adopts the stack first with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? makeInMemoryService(root, { isProcessAlive: () => false }) + : makePersistentService(root, { isProcessAlive: () => false }); + let stackRoot: string | undefined; + let dataFile: string | undefined; + + await expect( + service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + initialize: async (stack) => { + stackRoot = stack.paths.root; + dataFile = join(stack.paths.data, "database"); + writeFileSync(dataFile, "live data"); + const operation = service.repository + .listActiveOperations() + .find((candidate) => candidate.stackId === stack.id); + if (operation === undefined) { + throw new Error("Expected the provision operation to remain active"); + } + service.repository.reconcileOperation( + stack.id, + operation.token, + "running", + "2026-08-11T00:00:01.000Z", + ); + }, + }), + ).rejects.toMatchObject({ + cleanupErrors: [expect.any(ManagedOperationOwnershipError)], + }); + + expect(stackRoot).toBeDefined(); + expect(dataFile).toBeDefined(); + expect(existsSync(stackRoot ?? "")).toBe(true); + expect(readFileSync(dataFile ?? "", "utf8")).toBe("live data"); + expect(service.listStacks()).toEqual([ + expect.objectContaining({ status: "active", lifecycle: "running" }), + ]); + service.close(); + }); + } + + it("retains an operation when owner liveness cannot be determined", async () => { + const root = makeRoot(); + const livenessError = new Error("liveness unavailable"); + const service = makeInMemoryService(root, { + isProcessAlive: () => { + throw livenessError; + }, + }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_670); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + expect(reconciled.retained).toEqual([ + { + operation: pending.operation, + reason: "owner-liveness-unknown", + error: livenessError, + }, + ]); + }); + + it("retains an operation when runtime inspection fails", async () => { + const root = makeRoot(); + const inspectionError = new Error("runtime unavailable"); + const service = makeInMemoryService(root, { isProcessAlive: () => false }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_671); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => { + throw inspectionError; + }, + }); + + expect(reconciled.retained).toEqual([ + { + operation: pending.operation, + reason: "runtime-inspection-failed", + error: inspectionError, + }, + ]); + expect(service.repository.listActiveOperations()).toEqual([pending.operation]); + }); + + it("reports a failed post-abort state reclamation", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let returnUnsafePath = false; + const unsafeRoot = join(root, "outside"); + const guardedRepository: ManagedStackRepository = { + ...repository, + getStack(stackId) { + const stack = repository.getStack(stackId); + if (stack === undefined || !returnUnsafePath) { + return stack; + } + return { + ...stack, + paths: { + root: unsafeRoot, + data: join(unsafeRoot, "data"), + logs: join(unsafeRoot, "logs"), + runtime: join(unsafeRoot, "runtime"), + }, + }; + }, + }; + const service = makeManagedStackService({ + repository: guardedRepository, + stateRoot: join(root, "managed"), + isProcessAlive: () => false, + }); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_672); + returnUnsafePath = true; + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + expect(reconciled.abortedStackIds).toEqual([pending.stack.id]); + expect(reconciled.failures).toEqual([ + { + operation: pending.operation, + phase: "state-reclamation", + operationReleased: true, + error: expect.any(UnsafeManagedStackPathError), + }, + ]); + expect(service.listStacks()).toEqual([]); + }); + it("continues recovery when an owner finishes one operation during inspection", async () => { const root = makeRoot(); const service = makeInMemoryService(root, { isProcessAlive: () => false }); @@ -776,6 +985,64 @@ describe("managed repository and lifecycle", () => { expect(reconciled.skippedOperationIds).toEqual([firstOperation.operation.token]); }); + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`keeps a failed pending adoption retryable with ${adapter}`, async () => { + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? makeInMemoryService(root, overrides) + : makePersistentService(root, overrides); + const owner = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "owner"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 55_409, intent: "exact" }], + }, + }); + const pending = await prepareAbandonedStack( + service, + makeWorkspace(root, "pending"), + 987_673, + { ports: [{ key: "api.port", port: 55_409, intent: "exact" }] }, + ); + + const blocked = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(blocked.failures).toEqual([ + { + operation: pending.operation, + phase: "reconciliation", + operationReleased: false, + error: expect.any(ManagedPortReservationError), + }, + ]); + expect(service.inspectStack(pending.stack.id)).toMatchObject({ + status: "pending", + lifecycle: "stopped", + }); + expect(service.repository.listActiveOperations()).toEqual([pending.operation]); + + await service.updateStack(owner.stack.id, { lifecycle: "stopped" }); + const retried = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "running", + }); + + expect(retried.recovered).toEqual([ + expect.objectContaining({ + id: pending.stack.id, + status: "active", + lifecycle: "running", + }), + ]); + expect(retried.failures).toEqual([]); + expect(service.repository.listActiveOperations()).toEqual([]); + service.close(); + }); + } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { it(`releases a failed runtime adoption operation with ${adapter}`, async () => { const root = makeRoot(); @@ -1250,6 +1517,25 @@ describe("managed repository and lifecycle", () => { assert.equal(first.outcome, "create"); assert.equal(second.outcome, "reuse"); assert.equal(second.stack.id, first.stack.id); + const conflicting = secondRepository.claimOperation({ + token: randomUUID(), + stackId: second.stack.id, + kind: "update", + ownerPid: process.pid, + now: new Date().toISOString(), + }); + assert.equal(conflicting.acquired, true); + await assert.rejects( + secondService.updateStack(second.stack.id, { lifecycle: "running" }), + { name: "ManagedOperationInProgressError" }, + ); + if (!conflicting.acquired) throw new Error("Expected operation ownership"); + secondRepository.finishOperation( + second.stack.id, + conflicting.operation.token, + "completed", + new Date().toISOString(), + ); const deleted = await secondService.deleteStack(second.stack.id); const repeated = await secondService.deleteStack(second.stack.id); assert.equal(deleted.outcome, "delete"); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index 0fe1902ca2..ff0ddc785f 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -22,7 +22,7 @@ import { ensureOrdinaryWorkspaceIdentity, readOrdinaryWorkspaceIdentity, } from "./identity.ts"; -import { createManagedUuid } from "./ids.ts"; +import { assertManagedUuid, createManagedUuid } from "./ids.ts"; import { assertManagedStackRoot, managedStackPaths } from "./paths.ts"; import type { ManagedStackRepository } from "./repository.ts"; @@ -68,7 +68,10 @@ export interface DeleteManagedStackResult { export interface ReconcileAbandonedOperationsOptions { readonly startedBefore?: string; - readonly force?: boolean; + readonly force?: { + readonly stackId: string; + readonly operationToken: string; + }; readonly inspectRuntime: ( stack: ManagedStackRecord, operation: ManagedOperationRecord, @@ -197,7 +200,14 @@ export const makeManagedStackService = ( } }; - const failRecoveryBestEffort = (operation: ManagedOperationRecord, error: unknown): boolean => { + const failRecoveryBestEffort = ( + stack: ManagedStackRecord | undefined, + operation: ManagedOperationRecord, + error: unknown, + ): boolean => { + if (stack === undefined || stack.status === "pending") { + return false; + } try { options.repository.updateStack({ stackId: operation.stackId, @@ -344,16 +354,20 @@ export const makeManagedStackService = ( }; } catch (cause: unknown) { const cleanupErrors: Array = []; - try { - await removeStackState(prepared.stack); - } catch (error: unknown) { - cleanupErrors.push(error); - } + let aborted = false; try { options.repository.abortPendingStack(prepared.stack.id, prepared.operation.token); + aborted = true; } catch (error: unknown) { cleanupErrors.push(error); } + if (aborted) { + try { + await removeStackState(prepared.stack); + } catch (error: unknown) { + cleanupErrors.push(error); + } + } throw new ManagedStackInitializationError(prepared.stack.id, cause, cleanupErrors); } }, @@ -433,10 +447,23 @@ export const makeManagedStackService = ( const retained: Array = []; const skippedOperationIds: Array = []; const failures: Array = []; - for (const operation of options.repository.listActiveOperations( - reconcileOptions.startedBefore, - )) { - if (reconcileOptions.force !== true && operation.ownerPid !== undefined) { + const forcedOperation = reconcileOptions.force; + if (forcedOperation !== undefined) { + assertManagedUuid(forcedOperation.stackId, "forced recovery stackId"); + assertManagedUuid(forcedOperation.operationToken, "forced recovery operation token"); + } + const operations = options.repository + .listActiveOperations( + forcedOperation === undefined ? reconcileOptions.startedBefore : undefined, + ) + .filter( + (operation) => + forcedOperation === undefined || + (operation.stackId === forcedOperation.stackId && + operation.token === forcedOperation.operationToken), + ); + for (const operation of operations) { + if (forcedOperation === undefined && operation.ownerPid !== undefined) { try { if (await isProcessAlive(operation.ownerPid)) { retained.push({ operation, reason: "owner-alive" }); @@ -447,8 +474,9 @@ export const makeManagedStackService = ( continue; } } + let stack: ManagedStackRecord | undefined; try { - const stack = options.repository.getStack(operation.stackId); + stack = options.repository.getStack(operation.stackId); if (stack === undefined) { skippedOperationIds.push(operation.token); continue; @@ -497,7 +525,7 @@ export const makeManagedStackService = ( failures.push({ operation, phase: "reconciliation", - operationReleased: failRecoveryBestEffort(operation, error), + operationReleased: failRecoveryBestEffort(stack, operation, error), error, }); } From ec4c93fdb159c60b8f116eb11c7ce19c267833a5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Tue, 11 Aug 2026 16:26:15 +0200 Subject: [PATCH 05/18] chore(stack): tighten managed recovery contract --- packages/stack/docs/architecture.md | 21 ++++++++++++------ .../src/managed-service.integration.test.ts | 20 +++++++++++++++++ packages/stack/src/managed/service.ts | 22 ++++++++++++++----- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 355f10a381..327932f904 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -320,7 +320,10 @@ stack UUID: Schema v2 intentionally has no migration path for this unreleased POC. Before first use of v2, developers with `registry-v1.sqlite3` must remove the old managed state root, including its shared -`stacks/` directory; v1 and v2 state must not be kept side by side. +`stacks/` directory; v1 and v2 state must not be kept side by side. Recovery can also leave an +unregistered UUID stack root when a provisioner writes after its pending row was concurrently +aborted. The provision error reports the failed ownership cleanup, but there is no automatic orphan +garbage collection; remove that root only after independently confirming its runtime is stopped. Stack publication and operation claims are transactional. A new stack remains `pending` while its directories and caller-supplied initialization are validated, then becomes `active` atomically. @@ -332,12 +335,16 @@ PID liveness is deliberately conservative and assumes the managed root stays wit namespace. Because a PID is not a permanent process identity, callers can request forced recovery after trustworthy runtime inspection; this is also the required integration path for a state root shared across PID namespaces. Forced recovery requires an exact stack ID and operation token, -processes only that claim, and bypasses only its PID gate—never runtime inspection. Recovery results -distinguish live owners, unknown or failed liveness/runtime inspection, concurrent skips, -reconciliation failures, and post-abort data-reclamation failures. A failed reconciliation of an -active stack marks its lifecycle `failed` before best-effort claim release, preserving the -requirement for an explicit stop path before deletion. A failed pending-stack adoption retains its -claim so a later pass can retry without losing potentially live unpublished data. +processes only that claim, and bypasses only its PID gate—never runtime inspection. Forced recovery +and the `startedBefore` age filter are mutually exclusive. Recovery results distinguish live owners, +unknown or failed liveness/runtime inspection, concurrent skips, reconciliation failures, and +post-abort data-reclamation failures. A failed reconciliation of an active stack marks its lifecycle +`failed` before best-effort claim release, preserving the requirement for an explicit stop path +before deletion. A failed pending-stack adoption retains its claim so a later pass can retry without +losing potentially live unpublished data. That claim blocks other mutations, including deletion, +until normal reconciliation succeeds or the caller obtains its stack ID and token from +`repository.listActiveOperations()` and performs a scoped forced recovery after trustworthy runtime +inspection. Port assignments are sticky metadata, while port ownership is a lifecycle lease. Stopped stacks retain their assigned numbers without blocking other stopped stacks. Entering `starting`, diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 168c8cfcbc..1736bea464 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -692,6 +692,26 @@ describe("managed repository and lifecycle", () => { expect(service.listStacks()).toEqual([]); }); + it.each([ + ["stack ID", { stackId: "not-a-uuid", operationToken: crypto.randomUUID() }], + ["operation token", { stackId: crypto.randomUUID(), operationToken: "not-a-uuid" }], + ])("rejects a forced recovery with an invalid %s", async (_label, force) => { + const root = makeRoot(); + const service = makeInMemoryService(root); + let inspected = false; + + await expect( + service.reconcileAbandonedOperations({ + force, + inspectRuntime: async () => { + inspected = true; + return "stopped"; + }, + }), + ).rejects.toBeInstanceOf(InvalidManagedIdentityError); + expect(inspected).toBe(false); + }); + it("scopes forced recovery to one exact operation", async () => { const root = makeRoot(); const service = makeInMemoryService(root, { isProcessAlive: () => true }); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index ff0ddc785f..f1c1bcffb3 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -66,18 +66,28 @@ export interface DeleteManagedStackResult { | { readonly outcome: "retained"; readonly error: unknown }; } -export interface ReconcileAbandonedOperationsOptions { - readonly startedBefore?: string; - readonly force?: { - readonly stackId: string; - readonly operationToken: string; - }; +interface ReconcileAbandonedOperationsBaseOptions { readonly inspectRuntime: ( stack: ManagedStackRecord, operation: ManagedOperationRecord, ) => Promise<"running" | "stopped" | "unknown">; } +export type ReconcileAbandonedOperationsOptions = ReconcileAbandonedOperationsBaseOptions & + ( + | { + readonly startedBefore?: string; + readonly force?: never; + } + | { + readonly startedBefore?: never; + readonly force: { + readonly stackId: string; + readonly operationToken: string; + }; + } + ); + export interface RetainedManagedOperation { readonly operation: ManagedOperationRecord; readonly reason: From 6f9b2e6a36256851a5453a4fce92d2ab19bbd0db Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 07:35:19 +0200 Subject: [PATCH 06/18] fix(stack): apply review triage fixes and register managed error telemetry - apply requested configuration after awaiting pending publication - trim whitespace in managed state-root environment fallbacks - reject updates to tombstoned stacks in both repository adapters - add coded errors for invalid stack names and unstopped deletion - register managed stack errors in CLI error-actionability telemetry Co-Authored-By: Claude Fable 5 --- .../shared/telemetry/error-actionability.ts | 45 ++++++++ packages/stack/src/managed-paths.unit.test.ts | 34 ++++++ .../src/managed-service.integration.test.ts | 101 +++++++++++++++++- packages/stack/src/managed/model.ts | 18 ++++ packages/stack/src/managed/paths.ts | 6 +- packages/stack/src/managed/repository.ts | 5 + packages/stack/src/managed/service.ts | 33 ++++-- packages/stack/src/managed/sqlite.ts | 5 + 8 files changed, 234 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 6b8eced12d..363c59d1b4 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -102,6 +102,12 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "invalid_url", "internal_build", "invalid_config", + "managed_identity", + "managed_identity_conflict", + "managed_initialization", + "managed_recovery", + "managed_stack_name", + "managed_stack_running", "network", "not_found", "plan_limit", @@ -899,6 +905,45 @@ const externalActionabilityByTag: Record = { return { ...actionability.stopStack, fingerprint_suffix: "daemon_transport" }; }, + // @supabase/stack managed registry — every managed failure is a plain + // `Error` subclass of the single `ManagedStackError` root, so the concrete + // failure is carried by the stable `code` field rather than by a tag. + ManagedStackError: (error) => { + switch (readString(error, "code")) { + case "INVALID_MANAGED_IDENTITY": + return { ...actionability.invalidInput, fingerprint_suffix: "managed_identity" }; + case "DUPLICATE_MANAGED_IDENTITY": + return { ...actionability.invalidConfig, fingerprint_suffix: "managed_identity_conflict" }; + case "MANAGED_INVALID_STACK_NAME": + return { ...actionability.invalidInput, fingerprint_suffix: "managed_stack_name" }; + case "UNSUPPORTED_MANAGED_REGISTRY_VERSION": + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + case "MANAGED_STACK_NOT_FOUND": + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + // Another caller owns the stack right now; the remediation is to settle + // that operation before retrying. + case "MANAGED_OPERATION_IN_PROGRESS": + case "MANAGED_OPERATION_OWNERSHIP_MISMATCH": + case "MANAGED_STACK_PUBLICATION_TIMEOUT": + return { ...actionability.stopStack, fingerprint_suffix: "conflict" }; + case "MANAGED_OPERATION_REQUIRES_RECONCILIATION": + return { ...actionability.stopStack, fingerprint_suffix: "managed_recovery" }; + case "MANAGED_STACK_NOT_STOPPED": + case "MANAGED_RUNNING_STACK_PORT_CHANGE": + return { ...actionability.stopStack, fingerprint_suffix: "managed_stack_running" }; + case "MANAGED_PORT_ALREADY_RESERVED": + return { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }; + // The registry derives every stack root itself, so a path that fails the + // containment check means the CLI passed a rejected argument. + case "UNSAFE_MANAGED_STACK_PATH": + return { ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }; + case "MANAGED_STACK_INITIALIZATION_FAILED": + return { ...actionability.startStack, fingerprint_suffix: "managed_initialization" }; + default: + return actionability.unknown; + } + }, + // @supabase/process-compose — the CLI generates the process graph, so graph // invariants are internal bugs; runtime service failures are stack-state // problems the user resolves by restarting the stack. diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index 9f379ff290..7e4fe3e4d8 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -28,6 +28,40 @@ describe("managed paths", () => { ).toBe("/configured/supabase/managed"); }); + it("trims surrounding whitespace from a configured SUPABASE_HOME", () => { + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: " /configured/supabase " }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/configured/supabase/managed"); + }); + + it("treats whitespace-only state-root environment values as unset", () => { + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: " " }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/home/user/.local/state/supabase/managed"); + expect( + resolveManagedStateRoot({ + env: { XDG_STATE_HOME: "\t" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/home/user/.local/state/supabase/managed"); + expect( + resolveManagedStateRoot({ + env: { LOCALAPPDATA: " " }, + homeDir: "C:\\Users\\user", + platform: "win32", + }), + ).toBe("C:\\Users\\user/AppData/Local/Supabase/managed"); + }); + it("uses platform application-state directories by default", () => { expect(resolveManagedStateRoot({ env: {}, homeDir: "/home/user", platform: "linux" })).toBe( "/home/user/.local/state/supabase/managed", diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 1736bea464..00da6a34ec 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -23,12 +23,14 @@ import { import { DuplicateManagedIdentityError, InvalidManagedIdentityError, + InvalidManagedStackNameError, ManagedOperationInProgressError, ManagedOperationOwnershipError, ManagedPortReservationError, ManagedRunningStackPortChangeError, ManagedStackInitializationError, ManagedStackNotFoundError, + ManagedStackNotStoppedError, ManagedStackPublicationTimeoutError, UnsafeManagedStackPathError, UnsupportedManagedRegistryVersionError, @@ -244,9 +246,9 @@ describe("ordinary-folder managed stack contract", () => { const workspace = makeWorkspace(root); const service = makeInMemoryService(root); - await expect( - service.provisionOrdinaryStack({ workspacePath: workspace, stackName }), - ).rejects.toThrow(`Invalid managed stack name: ${stackName}`); + const provision = service.provisionOrdinaryStack({ workspacePath: workspace, stackName }); + await expect(provision).rejects.toBeInstanceOf(InvalidManagedStackNameError); + await expect(provision).rejects.toThrow(`Invalid managed stack name: ${stackName}`); expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); expect(service.listStacks()).toEqual([]); }); @@ -388,6 +390,44 @@ describe("ordinary-folder managed stack contract", () => { service.close(); }); + it("applies the requested configuration after awaiting another caller's publication", async () => { + const requested = { key: "api.port", port: 55_451, intent: "exact" } as const; + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = makePersistentService(root); + let releaseInitialization: () => void = () => {}; + const initializationGate = new Promise((resolve) => { + releaseInitialization = resolve; + }); + + const first = service.provisionOrdinaryStack({ + workspacePath: workspace, + initialize: async () => { + await initializationGate; + }, + }); + while (service.repository.listStacks().length === 0) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const second = service.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { ports: [requested], serviceVersions: { postgres: "17.6.1.143" } }, + }); + releaseInitialization(); + const [created, reused] = await Promise.all([first, second]); + + expect(created.outcome).toBe("create"); + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.id).toBe(created.stack.id); + expect(reused.stack.ports).toEqual([requested]); + expect(reused.stack.serviceVersions).toEqual({ postgres: "17.6.1.143" }); + expect(service.inspectStack(created.stack.id)).toMatchObject({ + ports: [requested], + serviceVersions: { postgres: "17.6.1.143" }, + }); + service.close(); + }); + it("rolls back failed initialization and makes the same start retryable", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); @@ -1345,6 +1385,61 @@ describe("managed repository and lifecycle", () => { }); } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to resurrect a tombstoned stack with ${adapter}`, async () => { + const reserved = { key: "api.port", port: 55_461, intent: "exact" } as const; + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + const deleted = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "deleted"), + configuration: { lifecycle: "running", ports: [reserved] }, + }); + await service.deleteStack(deleted.stack.id, { stop: async () => {} }); + + await expect( + service.updateStack(deleted.stack.id, { lifecycle: "running", ports: [reserved] }), + ).rejects.toBeInstanceOf(ManagedStackNotFoundError); + + expect(service.inspectStack(deleted.stack.id)).toMatchObject({ + status: "tombstoned", + lifecycle: "stopped", + ports: [], + }); + expect(service.repository.listActiveOperations()).toEqual([]); + + const successor = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "successor"), + configuration: { lifecycle: "running", ports: [reserved] }, + }); + expect(successor.stack.ports).toEqual([reserved]); + service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to delete a running stack without a stop path with ${adapter}`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { lifecycle: "running" }, + }); + + await expect(service.deleteStack(created.stack.id)).rejects.toBeInstanceOf( + ManagedStackNotStoppedError, + ); + + expect(service.inspectStack(created.stack.id)).toMatchObject({ + status: "active", + lifecycle: "running", + }); + expect(service.repository.listActiveOperations()).toEqual([]); + service.close(); + }); + } + it("re-reads lifecycle after claiming delete before deciding whether to stop", async () => { const root = makeRoot(); const repository = createInMemoryManagedStackRepository(); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index b5f05741be..0633334c90 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -133,6 +133,15 @@ export class DuplicateManagedIdentityError extends ManagedStackError { } } +export class InvalidManagedStackNameError extends ManagedStackError { + readonly code = "MANAGED_INVALID_STACK_NAME"; + + constructor(readonly stackName: string) { + super(`Invalid managed stack name: ${stackName}`); + this.name = "InvalidManagedStackNameError"; + } +} + export class ManagedStackNotFoundError extends ManagedStackError { readonly code = "MANAGED_STACK_NOT_FOUND"; @@ -142,6 +151,15 @@ export class ManagedStackNotFoundError extends ManagedStackError { } } +export class ManagedStackNotStoppedError extends ManagedStackError { + readonly code = "MANAGED_STACK_NOT_STOPPED"; + + constructor(readonly stackId: string) { + super(`Managed stack ${stackId} must be safely stopped before deletion`); + this.name = "ManagedStackNotStoppedError"; + } +} + export class ManagedOperationInProgressError extends ManagedStackError { readonly code = "MANAGED_OPERATION_IN_PROGRESS"; diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index 74d3e81fbf..c28f354145 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -10,8 +10,10 @@ export interface ManagedStateRootOptions { readonly platform?: NodeJS.Platform; } -const nonEmpty = (value: string | undefined): string | undefined => - value === undefined || value.length === 0 ? undefined : value; +const nonEmpty = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +}; export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): string => { if (options.stateRoot !== undefined) { diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index ee62f305de..70efba1ee9 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -502,6 +502,11 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = updateStack(input) { requireOwnedOperation(input.stackId, input.operationToken); const current = requireStack(input.stackId); + if (current.status === "tombstoned") { + // A tombstone is deleted state: a caller holding a stale ID must never + // resurrect it into a port-occupying lifecycle. + throw new ManagedStackNotFoundError(input.stackId); + } const next = applyConfiguration(current, input, input.now); transitionPortOwnership(current, next); stacks.set(current.id, next); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index f1c1bcffb3..b3613e3bca 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -2,11 +2,13 @@ import { randomUUID } from "node:crypto"; import { mkdir, rm } from "node:fs/promises"; import { DEFAULT_MANAGED_STACK_NAME, + InvalidManagedStackNameError, ManagedAbandonedOperationError, ManagedOperationInProgressError, ManagedOperationOwnershipError, ManagedStackInitializationError, ManagedStackNotFoundError, + ManagedStackNotStoppedError, ManagedStackPublicationTimeoutError, type ManagedCheckoutLocation, type ManagedOperationKind, @@ -286,13 +288,26 @@ export const makeManagedStackService = ( } }; + /** + * Reused stacks adopt the caller's requested configuration regardless of + * whether the record was already published or was awaited while another + * caller published it, so the outcome never depends on that timing. + */ + const applyRequestedConfiguration = async ( + stack: ManagedStackRecord, + configuration: ManagedStackConfiguration | undefined, + ): Promise => + configuration === undefined || Object.keys(configuration).length === 0 + ? stack + : updateStackRecord(stack.id, configuration); + return { stateRoot: options.stateRoot, repository: options.repository, async provisionOrdinaryStack(provisionOptions) { const stackName = provisionOptions.stackName ?? DEFAULT_MANAGED_STACK_NAME; if (!stackNamePattern.test(stackName)) { - throw new Error(`Invalid managed stack name: ${stackName}`); + throw new InvalidManagedStackNameError(stackName); } const canonicalPath = await canonicalizeOrdinaryWorkspacePath(provisionOptions.workspacePath); const marker = await ensureOrdinaryWorkspaceIdentity(canonicalPath, idFactory); @@ -315,11 +330,10 @@ export const makeManagedStackService = ( if (prepared.operation !== undefined) { throw new ManagedOperationInProgressError(prepared.stack.id, prepared.operation); } - const stack = - provisionOptions.configuration === undefined || - Object.keys(provisionOptions.configuration).length === 0 - ? prepared.stack - : await updateStackRecord(prepared.stack.id, provisionOptions.configuration); + const stack = await applyRequestedConfiguration( + prepared.stack, + provisionOptions.configuration, + ); return { outcome: "reuse", selection: selectionForStack(stack), @@ -336,7 +350,10 @@ export const makeManagedStackService = ( ) { throw new ManagedAbandonedOperationError(prepared.stack.id); } - const published = await awaitPublication(prepared.stack); + const published = await applyRequestedConfiguration( + await awaitPublication(prepared.stack), + provisionOptions.configuration, + ); return { outcome: "reuse", selection: selectionForStack(published), @@ -431,7 +448,7 @@ export const makeManagedStackService = ( } if (current.lifecycle !== "stopped") { if (deleteOptions?.stop === undefined) { - throw new Error(`Managed stack ${stackId} must be safely stopped before deletion`); + throw new ManagedStackNotStoppedError(stackId); } await deleteOptions.stop(current); options.repository.updateStack({ diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index c42d1d1de9..eb13e87519 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -715,6 +715,11 @@ export const createSqliteManagedStackRepository = ( return transaction(database, () => { requireOwnedOperation(database, input.stackId, input.operationToken); const current = requireStack(database, input.stackId); + if (current.status === "tombstoned") { + // A tombstone is deleted state: a caller holding a stale ID must + // never resurrect it into a port-occupying lifecycle. + throw new ManagedStackNotFoundError(input.stackId); + } const runtimeRequest = input.runtimeRequest ?? current.runtimeRequest; const runtime = input.runtime ?? current.runtime; const lifecycle = input.lifecycle ?? current.lifecycle; From 96958bb2212330049aafd52693df4223a030008f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 08:13:34 +0200 Subject: [PATCH 07/18] fix(stack): route managed errors to telemetry and normalize managed inputs - dispatch managed stack errors to their actionability adapter by stable code - add coded InvalidManagedPortError for port validation failures - resolve injected managed state roots to absolute paths at the boundary Co-Authored-By: Claude Fable 5 --- .../shared/telemetry/error-actionability.ts | 128 ++++++++++++------ .../error-actionability.unit.test.ts | 40 ++++++ packages/stack/src/managed-model.unit.test.ts | 62 +++++++++ packages/stack/src/managed-paths.unit.test.ts | 22 +++ .../src/managed-service.integration.test.ts | 37 ++++- packages/stack/src/managed/model.ts | 12 ++ packages/stack/src/managed/paths.ts | 24 +++- packages/stack/src/managed/repository.ts | 3 +- packages/stack/src/managed/service.ts | 10 +- 9 files changed, 291 insertions(+), 47 deletions(-) create mode 100644 packages/stack/src/managed-model.unit.test.ts diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 363c59d1b4..acad347841 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -105,6 +105,7 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "managed_identity", "managed_identity_conflict", "managed_initialization", + "managed_port", "managed_recovery", "managed_stack_name", "managed_stack_running", @@ -761,6 +762,80 @@ const effectCliActionabilityByTag = { UnrecognizedOption: () => actionability.invalidInput, } satisfies Record; +/** + * `@supabase/stack` managed registry failures, keyed by the stable `code` + * literal each class declares. Every managed failure is a plain `Error` + * subclass of the single `ManagedStackError` root: none carries a `_tag`, and + * each subclass overwrites `name` with its own class name, so `code` is the + * only discriminator that both identifies the hierarchy and survives the + * identifier minification of release builds. This map is therefore the + * dispatch key as well as the classification table — see `classifyAtDepth`. + */ +const managedActionabilityByCode: Record = { + INVALID_MANAGED_IDENTITY: { + ...actionability.invalidInput, + fingerprint_suffix: "managed_identity", + }, + DUPLICATE_MANAGED_IDENTITY: { + ...actionability.invalidConfig, + fingerprint_suffix: "managed_identity_conflict", + }, + MANAGED_INVALID_STACK_NAME: { + ...actionability.invalidInput, + fingerprint_suffix: "managed_stack_name", + }, + UNSUPPORTED_MANAGED_REGISTRY_VERSION: { + ...actionability.invalidConfig, + fingerprint_suffix: "invalid_config", + }, + MANAGED_STACK_NOT_FOUND: { ...actionability.invalidInput, fingerprint_suffix: "not_found" }, + // Another caller owns the stack right now; the remediation is to settle that + // operation before retrying. + MANAGED_OPERATION_IN_PROGRESS: { ...actionability.stopStack, fingerprint_suffix: "conflict" }, + MANAGED_OPERATION_OWNERSHIP_MISMATCH: { + ...actionability.stopStack, + fingerprint_suffix: "conflict", + }, + MANAGED_STACK_PUBLICATION_TIMEOUT: { ...actionability.stopStack, fingerprint_suffix: "conflict" }, + MANAGED_OPERATION_REQUIRES_RECONCILIATION: { + ...actionability.stopStack, + fingerprint_suffix: "managed_recovery", + }, + MANAGED_STACK_NOT_STOPPED: { + ...actionability.stopStack, + fingerprint_suffix: "managed_stack_running", + }, + MANAGED_RUNNING_STACK_PORT_CHANGE: { + ...actionability.stopStack, + fingerprint_suffix: "managed_stack_running", + }, + MANAGED_PORT_ALREADY_RESERVED: { + ...actionability.invalidConfig, + fingerprint_suffix: "port_conflict", + }, + // The port number itself is unusable (fractional or outside 1-65535), which + // is the user's own configured value rather than a conflict with a peer. + MANAGED_INVALID_PORT: { ...actionability.invalidConfig, fingerprint_suffix: "managed_port" }, + // The registry derives every stack root itself, so a path that fails the + // containment check means the CLI passed a rejected argument. + UNSAFE_MANAGED_STACK_PATH: { + ...actionability.impossibleState, + fingerprint_suffix: "bad_argument", + }, + MANAGED_STACK_INITIALIZATION_FAILED: { + ...actionability.startStack, + fingerprint_suffix: "managed_initialization", + }, +}; + +function readManagedActionability( + error: ErrorRecord, +): CliErrorActionabilityDeclaration | undefined { + const code = readString(error, "code"); + if (code === undefined || !Object.hasOwn(managedActionabilityByCode, code)) return undefined; + return managedActionabilityByCode[code]; +} + const externalActionabilityByTag: Record = { ...effectCliActionabilityByTag, @@ -905,44 +980,10 @@ const externalActionabilityByTag: Record = { return { ...actionability.stopStack, fingerprint_suffix: "daemon_transport" }; }, - // @supabase/stack managed registry — every managed failure is a plain - // `Error` subclass of the single `ManagedStackError` root, so the concrete - // failure is carried by the stable `code` field rather than by a tag. - ManagedStackError: (error) => { - switch (readString(error, "code")) { - case "INVALID_MANAGED_IDENTITY": - return { ...actionability.invalidInput, fingerprint_suffix: "managed_identity" }; - case "DUPLICATE_MANAGED_IDENTITY": - return { ...actionability.invalidConfig, fingerprint_suffix: "managed_identity_conflict" }; - case "MANAGED_INVALID_STACK_NAME": - return { ...actionability.invalidInput, fingerprint_suffix: "managed_stack_name" }; - case "UNSUPPORTED_MANAGED_REGISTRY_VERSION": - return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; - case "MANAGED_STACK_NOT_FOUND": - return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; - // Another caller owns the stack right now; the remediation is to settle - // that operation before retrying. - case "MANAGED_OPERATION_IN_PROGRESS": - case "MANAGED_OPERATION_OWNERSHIP_MISMATCH": - case "MANAGED_STACK_PUBLICATION_TIMEOUT": - return { ...actionability.stopStack, fingerprint_suffix: "conflict" }; - case "MANAGED_OPERATION_REQUIRES_RECONCILIATION": - return { ...actionability.stopStack, fingerprint_suffix: "managed_recovery" }; - case "MANAGED_STACK_NOT_STOPPED": - case "MANAGED_RUNNING_STACK_PORT_CHANGE": - return { ...actionability.stopStack, fingerprint_suffix: "managed_stack_running" }; - case "MANAGED_PORT_ALREADY_RESERVED": - return { ...actionability.invalidConfig, fingerprint_suffix: "port_conflict" }; - // The registry derives every stack root itself, so a path that fails the - // containment check means the CLI passed a rejected argument. - case "UNSAFE_MANAGED_STACK_PATH": - return { ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }; - case "MANAGED_STACK_INITIALIZATION_FAILED": - return { ...actionability.startStack, fingerprint_suffix: "managed_initialization" }; - default: - return actionability.unknown; - } - }, + // @supabase/stack managed registry — see {@link managedActionabilityByCode}. + // Reached from `classifyAtDepth` by code, not by tag: managed failures have + // no `_tag`. + ManagedStackError: (error) => readManagedActionability(error) ?? actionability.unknown, // @supabase/process-compose — the CLI generates the process graph, so graph // invariants are internal bugs; runtime service failures are stack-state @@ -1108,6 +1149,17 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { } } + // Managed registry failures are untagged, and each subclass renames itself, + // so a recognized `code` literal is what routes them to their adapter. They + // all report under the hierarchy root: the concrete failure is already + // carried by the declaration's fingerprint suffix. + if (isErrorRecord(error) && readManagedActionability(error) !== undefined) { + const classify = externalActionabilityByTag["ManagedStackError"]; + if (classify !== undefined) { + return toActionability(classify(error), "error", "ManagedStackError"); + } + } + if (typeof error === "string") { return toActionability(actionability.unknown, "string", undefined); } diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 82ec77a8e1..58ea821219 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -598,6 +598,46 @@ describe("classifyCliErrorActionability", () => { expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); }); + // Managed registry errors are plain `Error` subclasses that rename themselves + // and carry no `_tag`, so `code` is the only thing routing them to their + // adapter. `managed-model.unit.test.ts` in `@supabase/stack` pins the real + // classes to the (name, code) pairs reproduced here. + it.each([ + [ + "InvalidManagedIdentityError", + "INVALID_MANAGED_IDENTITY", + "managed_identity", + "invalid_input", + ], + [ + "ManagedOperationInProgressError", + "MANAGED_OPERATION_IN_PROGRESS", + "conflict", + "invalid_config", + ], + ["InvalidManagedPortError", "MANAGED_INVALID_PORT", "managed_port", "invalid_config"], + [ + "UnsafeManagedStackPathError", + "UNSAFE_MANAGED_STACK_PATH", + "bad_argument", + "impossible_state", + ], + ])("classifies %s by its stable managed code", (name, code, suffix, category) => { + const error = new Error("managed registry failure"); + error.name = name; + Object.defineProperty(error, "code", { value: code }); + const result = classifyCliErrorActionability(error); + expect(result.error_category).toBe(category); + expect(result.error_fingerprint).toBe(`error:ManagedStackError:${suffix}`); + }); + + it("leaves an unrecognized managed-shaped code unclassified", () => { + const unrecognized = new Error("managed failure"); + unrecognized.name = "ManagedFutureError"; + Object.defineProperty(unrecognized, "code", { value: "MANAGED_FUTURE_FAILURE" }); + expect(classifyCliErrorActionability(unrecognized).error_kind).toBe("unknown"); + }); + it("classifies the preserved tagged cause of a StackError wrapper", () => { const wrapped = new Error("stack failure"); wrapped.name = "StackError"; diff --git a/packages/stack/src/managed-model.unit.test.ts b/packages/stack/src/managed-model.unit.test.ts new file mode 100644 index 0000000000..3143b98db7 --- /dev/null +++ b/packages/stack/src/managed-model.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + DuplicateManagedIdentityError, + InvalidManagedIdentityError, + InvalidManagedPortError, + InvalidManagedStackNameError, + ManagedAbandonedOperationError, + ManagedOperationInProgressError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackError, + ManagedStackInitializationError, + ManagedStackNotFoundError, + ManagedStackNotStoppedError, + ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, + UnsupportedManagedRegistryVersionError, +} from "./managed/model.ts"; + +const operation = { + token: "018f8b4e-8e5c-7e32-a956-6f297fd05a2d", + stackId: "stack-id", + kind: "start", + status: "active", + startedAt: "2026-08-11T00:00:00.000Z", +} as const; + +/** + * Consumers cannot discriminate managed failures by class: they are plain + * `Error` subclasses with no tag, and identifier minification renames the + * constructors. The CLI's telemetry classifier therefore dispatches on `code` + * (`apps/cli/src/shared/telemetry/error-actionability.ts`), so these literals + * are a published contract rather than an implementation detail. + */ +describe("managed error contract", () => { + it.each([ + [new InvalidManagedIdentityError("bad id"), "INVALID_MANAGED_IDENTITY"], + [new UnsupportedManagedRegistryVersionError(3, 2), "UNSUPPORTED_MANAGED_REGISTRY_VERSION"], + [new DuplicateManagedIdentityError("id", "a", "b"), "DUPLICATE_MANAGED_IDENTITY"], + [new InvalidManagedStackNameError("Bad Name"), "MANAGED_INVALID_STACK_NAME"], + [new InvalidManagedPortError(70_000, "api.port"), "MANAGED_INVALID_PORT"], + [new ManagedStackNotFoundError("stack-id"), "MANAGED_STACK_NOT_FOUND"], + [new ManagedStackNotStoppedError("stack-id"), "MANAGED_STACK_NOT_STOPPED"], + [new ManagedOperationInProgressError("stack-id", operation), "MANAGED_OPERATION_IN_PROGRESS"], + [new ManagedOperationOwnershipError("stack-id"), "MANAGED_OPERATION_OWNERSHIP_MISMATCH"], + [new ManagedPortReservationError(54_321, "stack-id"), "MANAGED_PORT_ALREADY_RESERVED"], + [new ManagedRunningStackPortChangeError("stack-id"), "MANAGED_RUNNING_STACK_PORT_CHANGE"], + [new UnsafeManagedStackPathError("/tmp/escaped"), "UNSAFE_MANAGED_STACK_PATH"], + [ + new ManagedStackInitializationError("stack-id", new Error("boom")), + "MANAGED_STACK_INITIALIZATION_FAILED", + ], + [new ManagedStackPublicationTimeoutError("stack-id"), "MANAGED_STACK_PUBLICATION_TIMEOUT"], + [new ManagedAbandonedOperationError("stack-id"), "MANAGED_OPERATION_REQUIRES_RECONCILIATION"], + ])("exposes a stable code and class name on $name", (error, code) => { + expect(error).toBeInstanceOf(ManagedStackError); + expect(error.code).toBe(code); + expect(error.name).toBe(error.constructor.name); + expect(error).not.toHaveProperty("_tag"); + }); +}); diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index 7e4fe3e4d8..27790605aa 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -1,3 +1,4 @@ +import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { assertManagedUuid } from "./managed/ids.ts"; import { InvalidManagedIdentityError, UnsafeManagedStackPathError } from "./managed/model.ts"; @@ -85,6 +86,27 @@ describe("managed paths", () => { ).toBe("C:\\Users\\user/AppData/Local/Supabase/managed"); }); + it("anchors caller- and environment-supplied state roots to an absolute path", () => { + expect(resolveManagedStateRoot({ stateRoot: "relative/managed" })).toBe( + resolve("relative/managed"), + ); + expect(resolveManagedStateRoot({ stateRoot: "/absolute/managed" })).toBe("/absolute/managed"); + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: "relative/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe(join(resolve("relative/supabase"), "managed")); + expect( + resolveManagedStateRoot({ + env: { XDG_STATE_HOME: "relative/state" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe(join(resolve("relative/state"), "supabase", "managed")); + }); + it("keys every mutable stack path by opaque stack ID", () => { expect(managedStackPaths("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d")).toEqual({ root: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d", diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 00da6a34ec..d76192c2e7 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -10,7 +10,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { managedStackContractFixtures } from "./managed-stack-contract.ts"; @@ -23,6 +23,7 @@ import { import { DuplicateManagedIdentityError, InvalidManagedIdentityError, + InvalidManagedPortError, InvalidManagedStackNameError, ManagedOperationInProgressError, ManagedOperationOwnershipError, @@ -529,6 +530,40 @@ describe("managed repository and lifecycle", () => { }); } + it("anchors an injected relative state root so a later chdir cannot split stack state", () => { + const service = makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: "relative-managed-state", + }); + expect(service.stateRoot).toBe(resolve("relative-managed-state")); + service.close(); + }); + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`rejects unusable port numbers with a coded failure for the ${adapter} adapter`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + const workspace = makeWorkspace(root); + + await expect( + service.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { ports: [{ key: "api.port", port: 54_321.5, intent: "exact" }] }, + }), + ).rejects.toBeInstanceOf(InvalidManagedPortError); + + const created = await service.provisionOrdinaryStack({ workspacePath: workspace }); + await expect( + service.updateStack(created.stack.id, { + ports: [{ key: "api.port", port: 70_000, intent: "exact" }], + }), + ).rejects.toBeInstanceOf(InvalidManagedPortError); + expect(service.inspectStack(created.stack.id)?.ports).toEqual([]); + service.close(); + }); + } + it("persists stack configuration and reserves ports globally", async () => { const root = makeRoot(); const service = makePersistentService(root); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index 0633334c90..0bb04d1f90 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -142,6 +142,18 @@ export class InvalidManagedStackNameError extends ManagedStackError { } } +export class InvalidManagedPortError extends ManagedStackError { + readonly code = "MANAGED_INVALID_PORT"; + + constructor( + readonly port: number, + readonly key: string, + ) { + super(`Invalid managed port ${port} for ${key}`); + this.name = "InvalidManagedPortError"; + } +} + export class ManagedStackNotFoundError extends ManagedStackError { readonly code = "MANAGED_STACK_NOT_FOUND"; diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index c28f354145..ea4af166fb 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -15,15 +15,23 @@ const nonEmpty = (value: string | undefined): string | undefined => { return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; }; +/** + * Every caller- or environment-supplied root is anchored to the working + * directory once, here. A relative root would otherwise be reinterpreted + * against whatever the process' cwd happens to be at each later use, so a + * chdir would split persisted stack state across directories and make + * {@link assertManagedStackRoot} accept a same-shaped path under the new cwd. + * `homedir()` is absolute by definition and needs no anchoring. + */ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): string => { if (options.stateRoot !== undefined) { - return options.stateRoot; + return resolve(options.stateRoot); } const env = options.env ?? process.env; const configuredHome = nonEmpty(env["SUPABASE_HOME"]); if (configuredHome !== undefined) { - return join(configuredHome, "managed"); + return join(resolve(configuredHome), "managed"); } const platform = options.platform ?? process.platform; @@ -33,11 +41,19 @@ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): } if (platform === "win32") { const localAppData = nonEmpty(env["LOCALAPPDATA"]); - return join(localAppData ?? join(userHome, "AppData", "Local"), "Supabase", "managed"); + return join( + localAppData === undefined ? join(userHome, "AppData", "Local") : resolve(localAppData), + "Supabase", + "managed", + ); } const stateHome = nonEmpty(env["XDG_STATE_HOME"]); - return join(stateHome ?? join(userHome, ".local", "state"), "supabase", "managed"); + return join( + stateHome === undefined ? join(userHome, ".local", "state") : resolve(stateHome), + "supabase", + "managed", + ); }; export const managedRegistryPath = (stateRoot: string): string => diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index 70efba1ee9..54acdb4e51 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -1,5 +1,6 @@ import { DuplicateManagedIdentityError, + InvalidManagedPortError, ManagedOperationOwnershipError, ManagedPortReservationError, ManagedRunningStackPortChangeError, @@ -133,7 +134,7 @@ export const validateManagedPortAssignments = ( const numbers = new Set(); for (const assignment of ports) { if (!Number.isInteger(assignment.port) || assignment.port < 1 || assignment.port > 65_535) { - throw new Error(`Invalid managed port ${assignment.port} for ${assignment.key}`); + throw new InvalidManagedPortError(assignment.port, assignment.key); } if (keys.has(assignment.key)) { throw new Error(`Duplicate managed port key ${assignment.key}`); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index b3613e3bca..0d8cb64e4b 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; import { DEFAULT_MANAGED_STACK_NAME, InvalidManagedStackNameError, @@ -174,6 +175,9 @@ const processIsAlive = (pid: number): boolean => { export const makeManagedStackService = ( options: ManagedStackServiceOptions, ): ManagedStackService => { + // Anchored once, at the boundary: a relative root injected here would be + // reinterpreted against the process' cwd at every later use. + const stateRoot = resolve(options.stateRoot); const idFactory = options.idFactory ?? randomUUID; const clock = options.clock ?? (() => new Date()); const ownerPid = options.ownerPid ?? process.pid; @@ -183,7 +187,7 @@ export const makeManagedStackService = ( const now = (): string => clock().toISOString(); const removeStackState = async (stack: ManagedStackRecord): Promise => { - const root = assertManagedStackRoot(options.stateRoot, stack.id, stack.paths.root); + const root = assertManagedStackRoot(stateRoot, stack.id, stack.paths.root); await rm(root, { force: true, recursive: true }); }; @@ -302,7 +306,7 @@ export const makeManagedStackService = ( : updateStackRecord(stack.id, configuration); return { - stateRoot: options.stateRoot, + stateRoot, repository: options.repository, async provisionOrdinaryStack(provisionOptions) { const stackName = provisionOptions.stackName ?? DEFAULT_MANAGED_STACK_NAME; @@ -318,7 +322,7 @@ export const makeManagedStackService = ( locationId: createManagedUuid(idFactory, "checkout location id"), stackId, stackName, - paths: managedStackPaths(options.stateRoot, stackId), + paths: managedStackPaths(stateRoot, stackId), operationToken: createManagedUuid(idFactory, "operation token"), ownerPid, now: now(), From 48ef6b3169c78c7c0c9f84e185d991b57150cd46 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 09:56:24 +0200 Subject: [PATCH 08/18] fix(stack): harden managed layer per deep review and prune dead surface - guard empty state roots, tombstoned reconciliation, pending updates, and owner pids - recurse initialization causes and link managed error codes mechanically - isolate the in-memory repository to the testing entrypoint and share entrypoint plumbing - align adapter ordering, split fingerprint suffixes, batch port reads - drop dead repository surface and unused context columns (schema v3) Co-Authored-By: Claude Fable 5 --- apps/cli/src/shared/config/supabase-home.ts | 9 +- .../error-actionability-coverage.unit.test.ts | 33 ++ .../shared/telemetry/error-actionability.ts | 98 +++- .../error-actionability.unit.test.ts | 95 +++- packages/stack/README.md | 2 +- packages/stack/docs/architecture.md | 29 +- packages/stack/package.json | 1 + packages/stack/src/entrypoints.unit.test.ts | 43 ++ packages/stack/src/managed-bun.ts | 39 +- packages/stack/src/managed-model.unit.test.ts | 96 ++-- packages/stack/src/managed-node.ts | 39 +- packages/stack/src/managed-paths.unit.test.ts | 24 + .../src/managed-service.integration.test.ts | 230 ++++++++- packages/stack/src/managed.ts | 14 +- packages/stack/src/managed/create-service.ts | 42 ++ packages/stack/src/managed/error-code.ts | 12 + packages/stack/src/managed/model.ts | 59 ++- packages/stack/src/managed/paths.ts | 11 +- .../stack/src/managed/repository-memory.ts | 465 +++++++++++++++++ packages/stack/src/managed/repository.ts | 480 ++---------------- packages/stack/src/managed/service.ts | 97 +++- packages/stack/src/managed/sqlite.ts | 122 +++-- packages/stack/src/testing.ts | 2 +- 23 files changed, 1379 insertions(+), 663 deletions(-) create mode 100644 packages/stack/src/managed/create-service.ts create mode 100644 packages/stack/src/managed/error-code.ts create mode 100644 packages/stack/src/managed/repository-memory.ts diff --git a/apps/cli/src/shared/config/supabase-home.ts b/apps/cli/src/shared/config/supabase-home.ts index 2333e4f63b..2824d04e24 100644 --- a/apps/cli/src/shared/config/supabase-home.ts +++ b/apps/cli/src/shared/config/supabase-home.ts @@ -10,9 +10,12 @@ import { join } from "node:path"; * This is the single source of truth for the `SUPABASE_HOME` contract in the * TypeScript CLI. It is a pure function: callers pass their own environment and * home directory so it stays trivially testable and free of global state. The - * legacy and next shells both resolve through it; libraries such as - * `@supabase/stack` never read `SUPABASE_HOME` themselves and instead receive - * the resolved path from the CLI. + * legacy and next shells both resolve through it, and every CLI call into + * `@supabase/stack` passes the root resolved here explicitly, so this stays the + * authoritative resolution for anything the CLI drives. Library-side fallbacks + * do exist for non-CLI embedders — the managed layer's `resolveManagedStateRoot` + * reads `SUPABASE_HOME` itself when no root is supplied (CLI-2106) — but the + * CLI never relies on them. */ export const resolveSupabaseHome = ( env: Readonly>, diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 609b58e1ba..7d282a7848 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -10,6 +10,7 @@ declare global { readonly glob: (patterns: ReadonlyArray) => Record Promise>; } } +import { MANAGED_ERROR_CODES } from "@supabase/stack/managed-model"; import { CliErrorCategory, CliErrorKind, @@ -17,6 +18,7 @@ import { ErrorActionabilityFingerprintId, ErrorActionabilityId, isClassifiedExternalErrorTag, + isClassifiedManagedErrorCode, } from "./error-actionability.ts"; /** @@ -195,6 +197,37 @@ describe("workspace package error tags have external adapters", () => { } }); +// Managed failures are `class X extends ManagedStackError` and carry no `_tag`, +// so ERROR_DEFINITION_PATTERN only ever sees the `ManagedStackError` root and +// none of its subclasses. They also have no per-class adapter — the CLI +// dispatches them by their `code` literal — so the guard has to scan for the +// (class, code) pairs and check the code side instead of the class name. +const MANAGED_SUBCLASS_PATTERN = + /class\s+([A-Za-z0-9_]+)\s+extends\s+ManagedStackError\s*\{\s*readonly\s+code\s*=\s*"([A-Z0-9_]+)"/gs; + +describe("managed registry error codes are classified", () => { + it("packages/stack/src/managed/model.ts", () => { + const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts"); + const matches = [...readFileSync(modelPath, "utf8").matchAll(MANAGED_SUBCLASS_PATTERN)]; + // One match per declared code: a subclass written in a shape this regex + // cannot see would otherwise pass vacuously instead of failing loudly. + expect(matches.length).toBe(MANAGED_ERROR_CODES.length); + const declaredCodes = new Set(MANAGED_ERROR_CODES); + for (const match of matches) { + const className = match[1] ?? ""; + const code = match[2] ?? ""; + expect( + declaredCodes.has(code), + `${className}'s code "${code}" is missing from MANAGED_ERROR_CODES`, + ).toBe(true); + expect( + isClassifiedManagedErrorCode(code), + `${className} ("${code}") has no entry in managedActionabilityByCode in error-actionability.ts`, + ).toBe(true); + } + }); +}); + describe("Effect CLI parser errors have exhaustive handling", () => { it("covers every exported parser error class", () => { const tags = new Set(); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index acad347841..bcbd1ee723 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -1,3 +1,4 @@ +import type { ManagedErrorCode } from "@supabase/stack/managed-model"; import { Cause, Option } from "effect"; import type { CliError as EffectCliError } from "effect/unstable/cli"; @@ -105,10 +106,16 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "managed_identity", "managed_identity_conflict", "managed_initialization", + "managed_operation_in_progress", + "managed_operation_ownership", + "managed_owner_pid", + "managed_pending_update", "managed_port", + "managed_port_change", + "managed_publication_timeout", "managed_recovery", "managed_stack_name", - "managed_stack_running", + "managed_stack_not_stopped", "network", "not_found", "plan_limit", @@ -770,8 +777,12 @@ const effectCliActionabilityByTag = { * only discriminator that both identifies the hierarchy and survives the * identifier minification of release builds. This map is therefore the * dispatch key as well as the classification table — see `classifyAtDepth`. + * + * Keyed by the package's exported {@link ManagedErrorCode} union, so the table + * is exhaustive by construction: a new managed failure cannot be added in + * `@supabase/stack` without being classified here. */ -const managedActionabilityByCode: Record = { +const managedActionabilityByCode: Record = { INVALID_MANAGED_IDENTITY: { ...actionability.invalidInput, fingerprint_suffix: "managed_identity", @@ -791,23 +802,41 @@ const managedActionabilityByCode: Record( + Object.entries(managedActionabilityByCode), +); + function readManagedActionability( error: ErrorRecord, ): CliErrorActionabilityDeclaration | undefined { const code = readString(error, "code"); - if (code === undefined || !Object.hasOwn(managedActionabilityByCode, code)) return undefined; - return managedActionabilityByCode[code]; + return code === undefined ? undefined : managedActionabilityLookup.get(code); +} + +/** + * Whether a `@supabase/stack` managed error code has a classification in + * {@link managedActionabilityByCode}. Used by the coverage test to keep the + * table exhaustive against the managed subclasses, which carry no `_tag` and + * therefore never reach {@link isClassifiedExternalErrorTag}. + */ +export function isClassifiedManagedErrorCode(code: string): boolean { + return managedActionabilityLookup.has(code); } const externalActionabilityByTag: Record = { @@ -981,8 +1026,10 @@ const externalActionabilityByTag: Record = { }, // @supabase/stack managed registry — see {@link managedActionabilityByCode}. - // Reached from `classifyAtDepth` by code, not by tag: managed failures have - // no `_tag`. + // Production dispatch never reaches this entry: `classifyAtDepth` routes + // managed failures by `code` (they carry no `_tag`). It stays because the + // hierarchy root is itself a plain Error subclass, which the coverage test's + // tag scan does pick up and requires an adapter for. ManagedStackError: (error) => readManagedActionability(error) ?? actionability.unknown, // @supabase/process-compose — the CLI generates the process graph, so graph @@ -1007,8 +1054,10 @@ export function isClassifiedExternalErrorTag(tag: string): boolean { /** * A wrapper's preserved `cause`, but only when classifying it cannot degrade - * the result: the cause must carry its own declaration or a known external - * adapter tag, otherwise the wrapper's own classification is more truthful. + * the result: the cause must carry its own declaration, a known external + * adapter tag, or a recognized managed `code` (managed failures have neither a + * declaration nor a `_tag`), otherwise the wrapper's own classification is more + * truthful. */ function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { const cause = error["cause"]; @@ -1016,6 +1065,7 @@ function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { if (readDeclaration(cause) !== undefined) return cause; const causeTag = readErrorTag(cause); if (causeTag !== undefined && Object.hasOwn(externalActionabilityByTag, causeTag)) return cause; + if (readManagedActionability(cause) !== undefined) return cause; return undefined; } @@ -1150,13 +1200,21 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { } // Managed registry failures are untagged, and each subclass renames itself, - // so a recognized `code` literal is what routes them to their adapter. They - // all report under the hierarchy root: the concrete failure is already - // carried by the declaration's fingerprint suffix. - if (isErrorRecord(error) && readManagedActionability(error) !== undefined) { - const classify = externalActionabilityByTag["ManagedStackError"]; - if (classify !== undefined) { - return toActionability(classify(error), "error", "ManagedStackError"); + // so a recognized `code` literal is what routes them to their declaration. + if (isErrorRecord(error)) { + const managed = readManagedActionability(error); + if (managed !== undefined) { + // ManagedStackInitializationError is only a wrapper: the real + // provisioning failure (a Docker pull, a config parse, ...) is preserved + // in `cause`, and the generic initialization verdict would hide the + // actionable one. + if (readString(error, "code") === "MANAGED_STACK_INITIALIZATION_FAILED") { + const cause = classifiableCause(error); + if (cause !== undefined) return classifyAtDepth(cause, depth + 1); + } + // Everything else reports under the hierarchy root: the concrete failure + // is already carried by the declaration's fingerprint suffix. + return toActionability(managed, "error", "ManagedStackError"); } } diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 58ea821219..103b094223 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -609,12 +609,6 @@ describe("classifyCliErrorActionability", () => { "managed_identity", "invalid_input", ], - [ - "ManagedOperationInProgressError", - "MANAGED_OPERATION_IN_PROGRESS", - "conflict", - "invalid_config", - ], ["InvalidManagedPortError", "MANAGED_INVALID_PORT", "managed_port", "invalid_config"], [ "UnsafeManagedStackPathError", @@ -622,6 +616,53 @@ describe("classifyCliErrorActionability", () => { "bad_argument", "impossible_state", ], + // The operation pid and the pending-update guard are both internal + // invariants: the CLI supplies the pid, and only repository misuse can + // update an unpublished row. + [ + "InvalidManagedOwnerPidError", + "MANAGED_INVALID_OWNER_PID", + "managed_owner_pid", + "impossible_state", + ], + [ + "ManagedPendingStackUpdateError", + "MANAGED_PENDING_STACK_UPDATE", + "managed_pending_update", + "impossible_state", + ], + // Each of these five used to share a suffix with an unrelated failure, so + // distinct defects grouped together as repeats (CLI-2106). + [ + "ManagedOperationInProgressError", + "MANAGED_OPERATION_IN_PROGRESS", + "managed_operation_in_progress", + "invalid_config", + ], + [ + "ManagedOperationOwnershipError", + "MANAGED_OPERATION_OWNERSHIP_MISMATCH", + "managed_operation_ownership", + "invalid_config", + ], + [ + "ManagedStackPublicationTimeoutError", + "MANAGED_STACK_PUBLICATION_TIMEOUT", + "managed_publication_timeout", + "invalid_config", + ], + [ + "ManagedStackNotStoppedError", + "MANAGED_STACK_NOT_STOPPED", + "managed_stack_not_stopped", + "invalid_config", + ], + [ + "ManagedRunningStackPortChangeError", + "MANAGED_RUNNING_STACK_PORT_CHANGE", + "managed_port_change", + "invalid_config", + ], ])("classifies %s by its stable managed code", (name, code, suffix, category) => { const error = new Error("managed registry failure"); error.name = name; @@ -638,6 +679,48 @@ describe("classifyCliErrorActionability", () => { expect(classifyCliErrorActionability(unrecognized).error_kind).toBe("unknown"); }); + // ManagedStackInitializationError wraps the real provisioning failure in + // `cause`; reporting the generic initialization verdict would lose it. + it("classifies the provisioning cause of a managed initialization failure", () => { + const wrapped = new Error("managed stack initialization failed"); + wrapped.name = "ManagedStackInitializationError"; + Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" }); + Object.defineProperty(wrapped, "cause", { + value: { _tag: "DockerPullError", image: "postgres", daemonDown: true }, + }); + expect(classifyCliErrorActionability(wrapped)).toEqual( + classifyCliErrorActionability({ + _tag: "DockerPullError", + image: "postgres", + daemonDown: true, + }), + ); + }); + + it("falls back to the managed initialization verdict for an opaque cause", () => { + const wrapped = new Error("managed stack initialization failed"); + wrapped.name = "ManagedStackInitializationError"; + Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" }); + Object.defineProperty(wrapped, "cause", { value: { detail: "opaque" } }); + const result = classifyCliErrorActionability(wrapped); + expect(result.error_kind).toBe("user_actionable"); + expect(result.suggested_command).toBe("supabase start"); + expect(result.error_fingerprint).toBe("error:ManagedStackError:managed_initialization"); + }); + + it("classifies a managed cause nested inside a stack wrapper", () => { + const managed = new Error("port already reserved"); + managed.name = "ManagedPortReservationError"; + Object.defineProperty(managed, "code", { value: "MANAGED_PORT_ALREADY_RESERVED" }); + const result = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "x", + cause: managed, + }); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("error:ManagedStackError:port_conflict"); + }); + it("classifies the preserved tagged cause of a StackError wrapper", () => { const wrapped = new Error("stack failure"); wrapped.name = "StackError"; diff --git a/packages/stack/README.md b/packages/stack/README.md index 3d204dc66b..6c286a1c40 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -60,7 +60,7 @@ managed.close(); Managed state uses opaque project, checkout, context, and stack UUIDs. A non-Git workspace stores only its three identity UUIDs in `.supabase/identity.json`; mutable state, logs, runtime metadata, ports, and lifecycle ownership live under the user-level managed state root. Callers can inject an -in-memory repository or an isolated state root for tests. Stopped stacks keep sticky port +isolated state root for tests, or the in-memory repository from `@supabase/stack/testing`. Stopped stacks keep sticky port assignments without holding a host-wide lease; exact configuration takes precedence when a stopped or failed stack is updated. A stack may change port numbers as part of one transition out of a port-occupying lifecycle; intent-only updates never count as runtime port drift. diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 327932f904..2015cf8ea2 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -311,16 +311,19 @@ stack UUID: ```text / - registry-v2.sqlite3 + registry-v3.sqlite3 stacks// data/ logs/ runtime/ ``` -Schema v2 intentionally has no migration path for this unreleased POC. Before first use of v2, -developers with `registry-v1.sqlite3` must remove the old managed state root, including its shared -`stacks/` directory; v1 and v2 state must not be kept side by side. Recovery can also leave an +Schema v3 intentionally has no migration path for this unreleased POC. Before first use of v3, +developers holding any earlier `registry-v*.sqlite3` must remove the old managed state root, +including its shared `stacks/` directory; registry generations must not be kept side by side. +The state root is required to be a non-empty path wherever it is passed explicitly, so a blank +value fails instead of silently anchoring managed state to the process' working directory. +Recovery can also leave an unregistered UUID stack root when a provisioner writes after its pending row was concurrently aborted. The provision error reports the failed ownership cleanup, but there is no automatic orphan garbage collection; remove that root only after independently confirming its runtime is stopped. @@ -330,6 +333,11 @@ directories and caller-supplied initialization are validated, then becomes `acti Concurrent callers resolve the published record rather than creating aliases. Recovery first retains claims whose owner process is still alive. Once an owner is gone, runtime inspection either publishes a running pending stack or aborts a stopped pending stack so the same identity can retry. +An abandoned claim over an already tombstoned row is a deletion that died before releasing it: +recovery finishes that deletion instead of reconciling a lifecycle. It never revives the row and +never drops the tombstone, since idempotent deletion depends on it; it releases the claim and +reclaims the leaked stack directory, reporting a failed removal like any other reclamation failure. +Reconciliation is therefore repeatable: a second pass over the same crashed deletion is a no-op. Ownership races are isolated per operation so one completed claim does not stop the recovery pass. PID liveness is deliberately conservative and assumes the managed root stays within one host PID namespace. Because a PID is not a permanent process identity, callers can request forced recovery @@ -337,8 +345,8 @@ after trustworthy runtime inspection; this is also the required integration path shared across PID namespaces. Forced recovery requires an exact stack ID and operation token, processes only that claim, and bypasses only its PID gate—never runtime inspection. Forced recovery and the `startedBefore` age filter are mutually exclusive. Recovery results distinguish live owners, -unknown or failed liveness/runtime inspection, concurrent skips, reconciliation failures, and -post-abort data-reclamation failures. A failed reconciliation of an active stack marks its lifecycle +unknown or failed liveness/runtime inspection, concurrent skips, reconciliation failures, +reclaimed tombstones from finished deletions, and post-abort data-reclamation failures. A failed reconciliation of an active stack marks its lifecycle `failed` before best-effort claim release, preserving the requirement for an explicit stop path before deletion. A failed pending-stack adoption retains its claim so a later pass can retry without losing potentially live unpublished data. That claim blocks other mutations, including deletion, @@ -401,7 +409,14 @@ not be used for new managed records. - `effect-bun.ts` and `effect-node.ts` are Effect export-condition targets. They bind foreground, daemon, and Unix-socket layers without exposing raw platform factories or bootstrap paths. - `managed-bun.ts` and `managed-node.ts` bind the same storage-independent managed service to the - runtime's built-in SQLite implementation. + runtime's built-in SQLite implementation. Both delegate to one shared factory + (`managed/create-service.ts`) parameterized by how a registry file is opened, so their option + surfaces cannot drift apart. The in-memory repository is not part of this entrypoint; it is a test + seam published through `@supabase/stack/testing`. +- `managed/model.ts` is exported as `@supabase/stack/managed-model` because it has no runtime + imports: consumers can read `MANAGED_ERROR_CODES` under either runtime without pulling in a SQLite + driver. The CLI's telemetry classifier types its managed dispatch table against that union, so a + new managed error code cannot be added without classifying it. - `daemon-bun.ts` is exported as `@supabase/stack/daemon-bun` so the compiled CLI can dispatch to it in-process. - `daemon-node.ts` is intentionally not a package export. The internal Node platform Adapter diff --git a/packages/stack/package.json b/packages/stack/package.json index fbc048c6aa..7b8124105d 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -16,6 +16,7 @@ "bun": "./src/managed-bun.ts", "default": "./src/managed-node.ts" }, + "./managed-model": "./src/managed/model.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 8eceae5dec..ac9f142064 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -45,6 +45,7 @@ describe("@supabase/stack entrypoints", () => { bun: "./src/managed-bun.ts", default: "./src/managed-node.ts", }, + "./managed-model": "./src/managed/model.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts", }); @@ -67,6 +68,48 @@ describe("@supabase/stack entrypoints", () => { expect(nodeRoot).not.toHaveProperty("createManagedStackService"); }); + it("pins the managed runtime surface so internals cannot leak into it", () => { + // The in-memory repository is a test seam and belongs to `./testing` only; + // the adapters' shared port and update guards stay module-internal. + expect(Object.keys(managed).sort()).toEqual([ + "DEFAULT_MANAGED_STACK_NAME", + "DuplicateManagedIdentityError", + "InvalidManagedIdentityError", + "InvalidManagedOwnerPidError", + "InvalidManagedPortError", + "InvalidManagedStackNameError", + "MANAGED_ERROR_CODES", + "MANAGED_REGISTRY_SCHEMA_VERSION", + "ManagedAbandonedOperationError", + "ManagedOperationInProgressError", + "ManagedOperationOwnershipError", + "ManagedPendingStackUpdateError", + "ManagedPortReservationError", + "ManagedRunningStackPortChangeError", + "ManagedStackError", + "ManagedStackInitializationError", + "ManagedStackNotFoundError", + "ManagedStackNotStoppedError", + "ManagedStackPublicationTimeoutError", + "ORDINARY_WORKSPACE_IDENTITY_VERSION", + "UnsafeManagedStackPathError", + "UnsupportedManagedRegistryVersionError", + "assertManagedStackRoot", + "assertManagedUuid", + "canonicalizeOrdinaryWorkspacePath", + "createManagedStackService", + "createManagedUuid", + "ensureOrdinaryWorkspaceIdentity", + "makeManagedStackService", + "managedRegistryPath", + "managedStackPaths", + "openBunSqliteManagedStackRepository", + "ordinaryWorkspaceIdentityPath", + "readOrdinaryWorkspaceIdentity", + "resolveManagedStateRoot", + ]); + }); + it("binds consumer Effect layers without exposing implementation tags", () => { expectTypeOf(nodeEffect.foregroundLayer).returns.toEqualTypeOf>(); expectTypeOf(bunEffect.foregroundLayer).returns.toEqualTypeOf>(); diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts index 621c341e1f..7136605679 100644 --- a/packages/stack/src/managed-bun.ts +++ b/packages/stack/src/managed-bun.ts @@ -1,37 +1,12 @@ -import { managedRegistryPath, resolveManagedStateRoot } from "./managed/paths.ts"; -import type { ManagedStackRepository } from "./managed/repository.ts"; -import { makeManagedStackService } from "./managed/service.ts"; +import { + createManagedStackServiceWith, + type CreateManagedStackServiceOptions, +} from "./managed/create-service.ts"; import { openBunSqliteManagedStackRepository } from "./managed/sqlite-bun.ts"; export * from "./managed.ts"; export { openBunSqliteManagedStackRepository }; +export type { CreateManagedStackServiceOptions }; -export interface CreateManagedStackServiceOptions { - readonly stateRoot?: string; - readonly repository?: ManagedStackRepository; - readonly env?: Readonly>; - readonly homeDir?: string; - readonly platform?: NodeJS.Platform; - readonly idFactory?: () => string; - readonly clock?: () => Date; - readonly ownerPid?: number; - readonly publicationTimeoutMs?: number; - readonly publicationPollMs?: number; - readonly isProcessAlive?: (pid: number) => boolean | Promise; -} - -export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => { - const stateRoot = resolveManagedStateRoot(options); - const repository = - options.repository ?? openBunSqliteManagedStackRepository(managedRegistryPath(stateRoot)); - return makeManagedStackService({ - repository, - stateRoot, - idFactory: options.idFactory, - clock: options.clock, - ownerPid: options.ownerPid, - publicationTimeoutMs: options.publicationTimeoutMs, - publicationPollMs: options.publicationPollMs, - isProcessAlive: options.isProcessAlive, - }); -}; +export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => + createManagedStackServiceWith(openBunSqliteManagedStackRepository, options); diff --git a/packages/stack/src/managed-model.unit.test.ts b/packages/stack/src/managed-model.unit.test.ts index 3143b98db7..277ed0c0b2 100644 --- a/packages/stack/src/managed-model.unit.test.ts +++ b/packages/stack/src/managed-model.unit.test.ts @@ -1,30 +1,35 @@ import { describe, expect, it } from "vitest"; -import { - DuplicateManagedIdentityError, - InvalidManagedIdentityError, - InvalidManagedPortError, - InvalidManagedStackNameError, - ManagedAbandonedOperationError, - ManagedOperationInProgressError, - ManagedOperationOwnershipError, - ManagedPortReservationError, - ManagedRunningStackPortChangeError, - ManagedStackError, - ManagedStackInitializationError, - ManagedStackNotFoundError, - ManagedStackNotStoppedError, - ManagedStackPublicationTimeoutError, - UnsafeManagedStackPathError, - UnsupportedManagedRegistryVersionError, -} from "./managed/model.ts"; +import * as model from "./managed/model.ts"; +import { MANAGED_ERROR_CODES, ManagedStackError } from "./managed/model.ts"; -const operation = { - token: "018f8b4e-8e5c-7e32-a956-6f297fd05a2d", - stackId: "stack-id", - kind: "start", - status: "active", - startedAt: "2026-08-11T00:00:00.000Z", -} as const; +interface ManagedErrorCase { + readonly exportName: string; + readonly error: Error; + readonly code: unknown; +} + +/** + * Every exported strict subclass of {@link ManagedStackError}, discovered by + * reflection rather than by hand: a subclass added without a registered code + * must fail here instead of silently classifying as `unknown` downstream. + * `prototype instanceof ManagedStackError` is false for the root itself, which + * is exactly the set we want. Each class is probed with placeholder + * constructor arguments because `code` is a field initializer rather than a + * parameter: only the message interpolation reads them. + */ +const CONSTRUCTOR_PROBE = [{}, {}, {}]; + +const managedErrorCases: ReadonlyArray = Object.entries(model).flatMap( + ([exportName, value]) => { + if (typeof value !== "function") return []; + const prototype: unknown = value.prototype; + if (typeof prototype !== "object" || prototype === null) return []; + if (!(prototype instanceof ManagedStackError)) return []; + const error: unknown = Reflect.construct(value, CONSTRUCTOR_PROBE); + if (!(error instanceof Error)) return []; + return [{ exportName, error, code: Reflect.get(error, "code") }]; + }, +); /** * Consumers cannot discriminate managed failures by class: they are plain @@ -34,29 +39,22 @@ const operation = { * are a published contract rather than an implementation detail. */ describe("managed error contract", () => { - it.each([ - [new InvalidManagedIdentityError("bad id"), "INVALID_MANAGED_IDENTITY"], - [new UnsupportedManagedRegistryVersionError(3, 2), "UNSUPPORTED_MANAGED_REGISTRY_VERSION"], - [new DuplicateManagedIdentityError("id", "a", "b"), "DUPLICATE_MANAGED_IDENTITY"], - [new InvalidManagedStackNameError("Bad Name"), "MANAGED_INVALID_STACK_NAME"], - [new InvalidManagedPortError(70_000, "api.port"), "MANAGED_INVALID_PORT"], - [new ManagedStackNotFoundError("stack-id"), "MANAGED_STACK_NOT_FOUND"], - [new ManagedStackNotStoppedError("stack-id"), "MANAGED_STACK_NOT_STOPPED"], - [new ManagedOperationInProgressError("stack-id", operation), "MANAGED_OPERATION_IN_PROGRESS"], - [new ManagedOperationOwnershipError("stack-id"), "MANAGED_OPERATION_OWNERSHIP_MISMATCH"], - [new ManagedPortReservationError(54_321, "stack-id"), "MANAGED_PORT_ALREADY_RESERVED"], - [new ManagedRunningStackPortChangeError("stack-id"), "MANAGED_RUNNING_STACK_PORT_CHANGE"], - [new UnsafeManagedStackPathError("/tmp/escaped"), "UNSAFE_MANAGED_STACK_PATH"], - [ - new ManagedStackInitializationError("stack-id", new Error("boom")), - "MANAGED_STACK_INITIALIZATION_FAILED", - ], - [new ManagedStackPublicationTimeoutError("stack-id"), "MANAGED_STACK_PUBLICATION_TIMEOUT"], - [new ManagedAbandonedOperationError("stack-id"), "MANAGED_OPERATION_REQUIRES_RECONCILIATION"], - ])("exposes a stable code and class name on $name", (error, code) => { - expect(error).toBeInstanceOf(ManagedStackError); - expect(error.code).toBe(code); - expect(error.name).toBe(error.constructor.name); - expect(error).not.toHaveProperty("_tag"); + it("keeps MANAGED_ERROR_CODES exhaustive against the exported subclasses", () => { + expect(managedErrorCases.length).toBe(MANAGED_ERROR_CODES.length); + expect(managedErrorCases.map(({ code }) => code).sort()).toEqual( + [...MANAGED_ERROR_CODES].sort(), + ); + }); + + it.each(managedErrorCases)("exposes a stable code and class name on $exportName", (testCase) => { + expect(testCase.error).toBeInstanceOf(ManagedStackError); + expect(typeof testCase.code).toBe("string"); + expect(MANAGED_ERROR_CODES).toContain(testCase.code); + expect(testCase.error.name).toBe(testCase.exportName); + expect(testCase.error).not.toHaveProperty("_tag"); + }); + + it.each([...MANAGED_ERROR_CODES])("declares %s on exactly one subclass", (code) => { + expect(managedErrorCases.filter((testCase) => testCase.code === code)).toHaveLength(1); }); }); diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts index 684d29bbb0..3792a3509d 100644 --- a/packages/stack/src/managed-node.ts +++ b/packages/stack/src/managed-node.ts @@ -1,37 +1,12 @@ -import { managedRegistryPath, resolveManagedStateRoot } from "./managed/paths.ts"; -import type { ManagedStackRepository } from "./managed/repository.ts"; -import { makeManagedStackService } from "./managed/service.ts"; +import { + createManagedStackServiceWith, + type CreateManagedStackServiceOptions, +} from "./managed/create-service.ts"; import { openNodeSqliteManagedStackRepository } from "./managed/sqlite-node.ts"; export * from "./managed.ts"; export { openNodeSqliteManagedStackRepository }; +export type { CreateManagedStackServiceOptions }; -export interface CreateManagedStackServiceOptions { - readonly stateRoot?: string; - readonly repository?: ManagedStackRepository; - readonly env?: Readonly>; - readonly homeDir?: string; - readonly platform?: NodeJS.Platform; - readonly idFactory?: () => string; - readonly clock?: () => Date; - readonly ownerPid?: number; - readonly publicationTimeoutMs?: number; - readonly publicationPollMs?: number; - readonly isProcessAlive?: (pid: number) => boolean | Promise; -} - -export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => { - const stateRoot = resolveManagedStateRoot(options); - const repository = - options.repository ?? openNodeSqliteManagedStackRepository(managedRegistryPath(stateRoot)); - return makeManagedStackService({ - repository, - stateRoot, - idFactory: options.idFactory, - clock: options.clock, - ownerPid: options.ownerPid, - publicationTimeoutMs: options.publicationTimeoutMs, - publicationPollMs: options.publicationPollMs, - isProcessAlive: options.isProcessAlive, - }); -}; +export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => + createManagedStackServiceWith(openNodeSqliteManagedStackRepository, options); diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index 27790605aa..f45c65766c 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -107,6 +107,30 @@ describe("managed paths", () => { ).toBe(join(resolve("relative/state"), "supabase", "managed")); }); + it("treats a blank explicit state root as unset", () => { + // `resolve("")` silently yields the process' cwd, which would scatter + // managed state across whatever directory the caller happened to run in. + for (const stateRoot of ["", " ", "\t"]) { + expect( + resolveManagedStateRoot({ stateRoot, env: {}, homeDir: "/home/user", platform: "linux" }), + ).toBe("/home/user/.local/state/supabase/managed"); + } + expect( + resolveManagedStateRoot({ + stateRoot: "", + env: { SUPABASE_HOME: "/configured/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).toBe("/configured/supabase/managed"); + }); + + it("trims surrounding whitespace from an explicit state root", () => { + expect(resolveManagedStateRoot({ stateRoot: " /absolute/managed " })).toBe( + "/absolute/managed", + ); + }); + it("keys every mutable stack path by opaque stack ID", () => { expect(managedStackPaths("/state", "018f8b4e-8e5c-7e32-a956-6f297fd05a2d")).toEqual({ root: "/state/stacks/018f8b4e-8e5c-7e32-a956-6f297fd05a2d", diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index d76192c2e7..6b37d49ead 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -23,8 +23,11 @@ import { import { DuplicateManagedIdentityError, InvalidManagedIdentityError, + MANAGED_REGISTRY_SCHEMA_VERSION, + InvalidManagedOwnerPidError, InvalidManagedPortError, InvalidManagedStackNameError, + ManagedPendingStackUpdateError, ManagedOperationInProgressError, ManagedOperationOwnershipError, ManagedPortReservationError, @@ -37,16 +40,15 @@ import { UnsupportedManagedRegistryVersionError, type ManagedStackConfiguration, } from "./managed/model.ts"; -import { - createInMemoryManagedStackRepository, - type ManagedStackRepository, -} from "./managed/repository.ts"; +import { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; +import type { ManagedStackRepository } from "./managed/repository.ts"; import { makeManagedStackService, type ManagedStackService, type ManagedStackServiceOptions, } from "./managed/service.ts"; import { openBunSqliteManagedStackRepository } from "./managed/sqlite-bun.ts"; +import { createManagedStackService } from "./managed-bun.ts"; const temporaryRoots: Array = []; @@ -108,6 +110,19 @@ const makePersistentService = ( }); }; +/** + * Valid managed UUIDs whose lexicographic order is the reverse of the order + * they are handed out in, so a repository that returns insertion order instead + * of sorting cannot accidentally pass an ordering assertion. + */ +const descendingIdFactory = (): (() => string) => { + let next = 0xff_ff_ff_00; + return () => { + next -= 1; + return `${next.toString(16).padStart(8, "0")}-0000-7000-8000-000000000000`; + }; +}; + const fixture = (id: string) => { const scenario = managedStackContractFixtures.find((candidate) => candidate.id === id); if (scenario === undefined) { @@ -510,7 +525,95 @@ describe("ordinary-folder managed stack contract", () => { }); }); +describe("managed service options", () => { + it.each([ + ["empty", ""], + ["whitespace", " "], + ["tab", "\t"], + ])( + "refuses an %s state root instead of falling back to the working directory", + (_case, stateRoot) => { + expect(() => + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot, + }), + ).toThrow(UnsafeManagedStackPathError); + }, + ); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "refuses %s as an operation owner pid", + (ownerPid) => { + const root = makeRoot(); + expect(() => + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid, + }), + ).toThrow(InvalidManagedOwnerPidError); + }, + ); + + it("validates owner pids on the shared entrypoint options path too", () => { + const root = makeRoot(); + expect(() => + createManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid: 0, + }), + ).toThrow(InvalidManagedOwnerPidError); + + const service = createManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid: 4321, + }); + expect(service.stateRoot).toBe(join(root, "managed")); + service.close(); + }); +}); + describe("managed repository and lifecycle", () => { + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`orders records identically byte-for-byte with the ${adapter} adapter`, async () => { + // Both adapters must agree on ordering: SQLite sorts `created_at, id` + // with BINARY collation, so the in-memory repository may not use + // `localeCompare`, whose case-insensitive collation disagrees on + // mixed-case paths. Descending IDs make insertion order the wrong answer. + const root = makeRoot(); + const overrides = { + clock: () => new Date("2026-08-11T00:00:00.000Z"), + idFactory: descendingIdFactory(), + }; + const service = + adapter === "in-memory" + ? makeInMemoryService(root, overrides) + : makePersistentService(root, overrides); + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "Projects"), + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "apps"), + }); + + expect(first.stack.createdAt).toBe(second.stack.createdAt); + expect(second.stack.id < first.stack.id).toBe(true); + expect(service.listStacks().map((stack) => stack.id)).toEqual( + [first.stack.id, second.stack.id].sort(), + ); + + const paths = service.repository + .listCheckoutLocations() + .map((location) => location.canonicalPath); + expect(paths).toEqual([...paths].sort()); + expect(paths).toHaveLength(2); + service.close(); + }); + } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { it(`keeps repository decisions storage-agnostic for the ${adapter} adapter`, async () => { const contract = fixture("api-boundary.repository-contract-is-storage-agnostic"); @@ -1452,6 +1555,105 @@ describe("managed repository and lifecycle", () => { }); } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + for (const runtime of ["running", "stopped"] as const) { + it(`finishes a crashed delete without resurrecting its tombstone with ${adapter} (${runtime} runtime)`, async () => { + // A tombstoned row under a claimed operation is a delete that died + // between tombstoning and releasing its claim. Recovery must finish the + // deletion, never revive the row into a lifecycle — whatever the + // runtime inspection reports about the dead owner's processes. + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? makeInMemoryService(root, overrides) + : makePersistentService(root, overrides); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + writeFileSync(join(created.stack.paths.data, "database"), "leaked"); + const claimed = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_680, + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + throw new Error("Expected the delete operation to be claimed"); + } + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => runtime, + }); + + expect(reconciled.reclaimedStackIds).toEqual([created.stack.id]); + expect(reconciled.recovered).toEqual([]); + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.failures).toEqual([]); + expect(reconciled.retained).toEqual([]); + expect(service.repository.listActiveOperations()).toEqual([]); + // The tombstone itself survives: idempotent deletion depends on it. + expect(service.inspectStack(created.stack.id)).toMatchObject({ + status: "tombstoned", + lifecycle: "stopped", + ports: [], + }); + expect(existsSync(created.stack.paths.root)).toBe(false); + + const repeated = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => runtime, + }); + + expect(repeated).toEqual({ + recovered: [], + abortedStackIds: [], + reclaimedStackIds: [], + retained: [], + skippedOperationIds: [], + failures: [], + }); + expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); + await expect(service.deleteStack(created.stack.id)).resolves.toMatchObject({ + outcome: "no-op", + }); + service.close(); + }); + } + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to reconfigure an unpublished pending stack with ${adapter}`, async () => { + // A pending row belongs to its publisher's provisioning flow. Letting a + // holder of the claim mutate its lifecycle would give a stack that no + // reader can see a port-occupying lease. + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), process.pid); + + expect(() => + service.repository.updateStack({ + stackId: pending.stack.id, + operationToken: pending.operation.token, + now: "2026-08-11T00:00:02.000Z", + lifecycle: "running", + }), + ).toThrow(ManagedPendingStackUpdateError); + + expect(service.inspectStack(pending.stack.id)).toMatchObject({ + status: "pending", + lifecycle: "stopped", + }); + service.close(); + }); + } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { it(`refuses to delete a running stack without a stop path with ${adapter}`, async () => { const root = makeRoot(); @@ -1751,15 +1953,29 @@ describe("managed repository and lifecycle", () => { ); }); - it("fails clearly instead of opening the obsolete development schema", () => { + it.each([1, 2])("fails clearly instead of opening obsolete development schema v%i", (version) => { const root = makeRoot(); - const databasePath = join(root, "obsolete.sqlite3"); + const databasePath = join(root, `obsolete-v${version}.sqlite3`); const database = new Database(databasePath, { create: true }); - database.exec("PRAGMA user_version = 1"); + database.exec(`PRAGMA user_version = ${version}`); database.close(); expect(() => openBunSqliteManagedStackRepository(databasePath)).toThrow( UnsupportedManagedRegistryVersionError, ); }); + + it("writes the current schema version into a fresh registry", () => { + const root = makeRoot(); + const databasePath = managedRegistryPath(join(root, "fresh")); + const repository = openBunSqliteManagedStackRepository(databasePath); + repository.close(); + + const database = new Database(databasePath, { readonly: true }); + expect(database.query("PRAGMA user_version").get()).toEqual({ + user_version: MANAGED_REGISTRY_SCHEMA_VERSION, + }); + database.close(); + expect(databasePath.endsWith("registry-v3.sqlite3")).toBe(true); + }); }); diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts index 46d14f788b..784ab97b33 100644 --- a/packages/stack/src/managed.ts +++ b/packages/stack/src/managed.ts @@ -2,5 +2,17 @@ export * from "./managed/identity.ts"; export * from "./managed/ids.ts"; export * from "./managed/model.ts"; export * from "./managed/paths.ts"; -export * from "./managed/repository.ts"; export * from "./managed/service.ts"; +// Only the repository contract is public. The port-ownership and update-guard +// helpers behind it are invariants the adapters share with each other, not API +// consumers can call meaningfully, and the in-memory adapter is a test seam +// exported through `@supabase/stack/testing` instead. +export type { + ClaimManagedOperationInput, + ClaimManagedOperationResult, + ManagedStackRepository, + PrepareOrdinaryStackInput, + PrepareOrdinaryStackResult, + ReconcileManagedOperationResult, + UpdateManagedStackInput, +} from "./managed/repository.ts"; diff --git a/packages/stack/src/managed/create-service.ts b/packages/stack/src/managed/create-service.ts new file mode 100644 index 0000000000..4f6caa8989 --- /dev/null +++ b/packages/stack/src/managed/create-service.ts @@ -0,0 +1,42 @@ +import { managedRegistryPath, resolveManagedStateRoot } from "./paths.ts"; +import type { ManagedStackRepository } from "./repository.ts"; +import { makeManagedStackService, type ManagedStackService } from "./service.ts"; + +export interface CreateManagedStackServiceOptions { + readonly stateRoot?: string; + readonly repository?: ManagedStackRepository; + readonly env?: Readonly>; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; + readonly idFactory?: () => string; + readonly clock?: () => Date; + readonly ownerPid?: number; + readonly publicationTimeoutMs?: number; + readonly publicationPollMs?: number; + readonly isProcessAlive?: (pid: number) => boolean | Promise; +} + +/** + * The whole body of every runtime entrypoint's `createManagedStackService`, + * parameterized only by how a registry file is opened. Keeping it here — rather + * than duplicating it per entrypoint — makes option drift between the Bun and + * Node entries structurally impossible, and lets the Bun test suite cover the + * plumbing that the Node entry (which imports `node:sqlite`) shares. + */ +export const createManagedStackServiceWith = ( + openRepository: (registryPath: string) => ManagedStackRepository, + options: CreateManagedStackServiceOptions, +): ManagedStackService => { + const stateRoot = resolveManagedStateRoot(options); + const repository = options.repository ?? openRepository(managedRegistryPath(stateRoot)); + return makeManagedStackService({ + repository, + stateRoot, + idFactory: options.idFactory, + clock: options.clock, + ownerPid: options.ownerPid, + publicationTimeoutMs: options.publicationTimeoutMs, + publicationPollMs: options.publicationPollMs, + isProcessAlive: options.isProcessAlive, + }); +}; diff --git a/packages/stack/src/managed/error-code.ts b/packages/stack/src/managed/error-code.ts new file mode 100644 index 0000000000..a77ceb2b16 --- /dev/null +++ b/packages/stack/src/managed/error-code.ts @@ -0,0 +1,12 @@ +/** + * The `code` carried by Node's filesystem/process errors and by the SQLite + * drivers. Reading it structurally keeps the managed layer free of driver + * imports and of message-text matching. + */ +export const errorCode = (error: unknown): string | undefined => { + if (typeof error !== "object" || error === null) { + return undefined; + } + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +}; diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index 0bb04d1f90..4bbd2f40bb 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -1,4 +1,4 @@ -export const MANAGED_REGISTRY_SCHEMA_VERSION = 2; +export const MANAGED_REGISTRY_SCHEMA_VERSION = 3; export const ORDINARY_WORKSPACE_IDENTITY_VERSION = 1; export const DEFAULT_MANAGED_STACK_NAME = "default"; @@ -142,6 +142,15 @@ export class InvalidManagedStackNameError extends ManagedStackError { } } +export class InvalidManagedOwnerPidError extends ManagedStackError { + readonly code = "MANAGED_INVALID_OWNER_PID"; + + constructor(readonly ownerPid: number) { + super(`Invalid managed operation owner pid ${ownerPid}`); + this.name = "InvalidManagedOwnerPidError"; + } +} + export class InvalidManagedPortError extends ManagedStackError { readonly code = "MANAGED_INVALID_PORT"; @@ -172,6 +181,17 @@ export class ManagedStackNotStoppedError extends ManagedStackError { } } +export class ManagedPendingStackUpdateError extends ManagedStackError { + readonly code = "MANAGED_PENDING_STACK_UPDATE"; + + constructor(readonly stackId: string) { + super( + `Managed stack ${stackId} is still pending publication and cannot be reconfigured through an update`, + ); + this.name = "ManagedPendingStackUpdateError"; + } +} + export class ManagedOperationInProgressError extends ManagedStackError { readonly code = "MANAGED_OPERATION_IN_PROGRESS"; @@ -253,3 +273,40 @@ export class ManagedAbandonedOperationError extends ManagedStackError { this.name = "ManagedAbandonedOperationError"; } } + +/** + * Every `code` literal declared by a {@link ManagedStackError} subclass. + * + * Managed failures are plain `Error` subclasses: none carries a `_tag`, and + * identifier minification renames the constructors, so `code` is the only + * discriminator consumers can dispatch on. This list is the machine-readable + * form of that contract. `managed-model.unit.test.ts` keeps it exhaustive + * against the exported classes, and the CLI's telemetry classifier types its + * dispatch table as `Record` so a new code cannot be + * added here without classifying it there. + * + * This module must stay free of runtime-specific imports: it is published as + * `@supabase/stack/managed-model` precisely so consumers can import the codes + * under Bun and Node alike, without pulling in a SQLite driver. + */ +export const MANAGED_ERROR_CODES = [ + "DUPLICATE_MANAGED_IDENTITY", + "INVALID_MANAGED_IDENTITY", + "MANAGED_INVALID_OWNER_PID", + "MANAGED_INVALID_PORT", + "MANAGED_INVALID_STACK_NAME", + "MANAGED_OPERATION_IN_PROGRESS", + "MANAGED_OPERATION_OWNERSHIP_MISMATCH", + "MANAGED_OPERATION_REQUIRES_RECONCILIATION", + "MANAGED_PENDING_STACK_UPDATE", + "MANAGED_PORT_ALREADY_RESERVED", + "MANAGED_RUNNING_STACK_PORT_CHANGE", + "MANAGED_STACK_INITIALIZATION_FAILED", + "MANAGED_STACK_NOT_FOUND", + "MANAGED_STACK_NOT_STOPPED", + "MANAGED_STACK_PUBLICATION_TIMEOUT", + "UNSAFE_MANAGED_STACK_PATH", + "UNSUPPORTED_MANAGED_REGISTRY_VERSION", +] as const; + +export type ManagedErrorCode = (typeof MANAGED_ERROR_CODES)[number]; diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index ea4af166fb..356f34c1a6 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -22,10 +22,15 @@ const nonEmpty = (value: string | undefined): string | undefined => { * chdir would split persisted stack state across directories and make * {@link assertManagedStackRoot} accept a same-shaped path under the new cwd. * `homedir()` is absolute by definition and needs no anchoring. + * + * A blank explicit root is treated as unset rather than resolved: `resolve("")` + * silently yields the process' working directory, which would scatter managed + * state across whatever directory a caller happened to start in. */ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): string => { - if (options.stateRoot !== undefined) { - return resolve(options.stateRoot); + const requested = nonEmpty(options.stateRoot); + if (requested !== undefined) { + return resolve(requested); } const env = options.env ?? process.env; @@ -57,7 +62,7 @@ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): }; export const managedRegistryPath = (stateRoot: string): string => - join(stateRoot, "registry-v2.sqlite3"); + join(stateRoot, "registry-v3.sqlite3"); export const managedStackPaths = (stateRoot: string, stackId: string): ManagedStackPaths => { assertManagedUuid(stackId, "stackId"); diff --git a/packages/stack/src/managed/repository-memory.ts b/packages/stack/src/managed/repository-memory.ts new file mode 100644 index 0000000000..c1b41bc238 --- /dev/null +++ b/packages/stack/src/managed/repository-memory.ts @@ -0,0 +1,465 @@ +import { + DuplicateManagedIdentityError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + type ManagedCheckoutLocation, + type ManagedOperationRecord, + type ManagedRuntimeMetadata, + type ManagedStackConfiguration, + type ManagedStackRecord, +} from "./model.ts"; +import { + assertManagedStackUpdatable, + compareManagedText, + managedStackOccupiesPorts, + reconcileManagedPortAssignments, + validateManagedPortAssignments, + type ClaimManagedOperationInput, + type ClaimManagedOperationResult, + type ManagedStackRepository, +} from "./repository.ts"; + +interface InMemoryCheckout { + readonly id: string; + readonly projectId: string; +} + +interface InMemoryContext { + readonly id: string; + readonly checkoutId: string; +} + +const stackIdentityKey = (checkoutId: string, contextId: string, stackName: string): string => + `${checkoutId}\u0000${contextId}\u0000${stackName}`; + +const copy = (value: A): A => structuredClone(value); + +const applyConfiguration = ( + stack: ManagedStackRecord, + configuration: ManagedStackConfiguration, + now: string, +): ManagedStackRecord => { + const lifecycle = configuration.lifecycle ?? stack.lifecycle; + return { + ...stack, + lifecycle, + runtimeRequest: configuration.runtimeRequest ?? stack.runtimeRequest, + runtime: configuration.runtime ?? stack.runtime, + ports: reconcileManagedPortAssignments(stack, configuration.ports, lifecycle), + serviceVersions: configuration.serviceVersions ?? stack.serviceVersions, + runtimeMetadata: configuration.runtimeMetadata ?? stack.runtimeMetadata, + configFingerprint: configuration.configFingerprint ?? stack.configFingerprint, + credentialsReference: configuration.credentialsReference ?? stack.credentialsReference, + updatedAt: now, + }; +}; + +const emptyRuntimeMetadata = (): ManagedRuntimeMetadata => ({ + processIds: {}, + containerIds: {}, +}); + +/** + * A test seam, exported only through `@supabase/stack/testing`: it lets + * consumers exercise the managed service without a SQLite driver, and it is the + * parity reference the persistent adapters are tested against. Production code + * must go through a persistent adapter instead. + */ +export const createInMemoryManagedStackRepository = (): ManagedStackRepository => { + const projects = new Set(); + const checkouts = new Map(); + const contexts = new Map(); + const locations = new Map(); + const stacks = new Map(); + const stackIdentities = new Map(); + const operations = new Map(); + const activeOperationByStack = new Map(); + const portOwners = new Map(); + + const atomic = (run: () => A): A => { + const snapshot = { + projects: structuredClone([...projects]), + checkouts: structuredClone([...checkouts]), + contexts: structuredClone([...contexts]), + locations: structuredClone([...locations]), + stacks: structuredClone([...stacks]), + stackIdentities: structuredClone([...stackIdentities]), + operations: structuredClone([...operations]), + activeOperationByStack: structuredClone([...activeOperationByStack]), + portOwners: structuredClone([...portOwners]), + }; + try { + return run(); + } catch (error: unknown) { + projects.clear(); + for (const project of snapshot.projects) projects.add(project); + checkouts.clear(); + for (const [key, value] of snapshot.checkouts) checkouts.set(key, value); + contexts.clear(); + for (const [key, value] of snapshot.contexts) contexts.set(key, value); + locations.clear(); + for (const [key, value] of snapshot.locations) locations.set(key, value); + stacks.clear(); + for (const [key, value] of snapshot.stacks) stacks.set(key, value); + stackIdentities.clear(); + for (const [key, value] of snapshot.stackIdentities) stackIdentities.set(key, value); + operations.clear(); + for (const [key, value] of snapshot.operations) operations.set(key, value); + activeOperationByStack.clear(); + for (const [key, value] of snapshot.activeOperationByStack) { + activeOperationByStack.set(key, value); + } + portOwners.clear(); + for (const [key, value] of snapshot.portOwners) portOwners.set(key, value); + throw error; + } + }; + + const requireStack = (stackId: string): ManagedStackRecord => { + const stack = stacks.get(stackId); + if (stack === undefined) { + throw new ManagedStackNotFoundError(stackId); + } + return stack; + }; + + const requireOwnedOperation = ( + stackId: string, + operationToken: string, + ): ManagedOperationRecord => { + const activeToken = activeOperationByStack.get(stackId); + const operation = operations.get(operationToken); + if ( + activeToken !== operationToken || + operation === undefined || + operation.stackId !== stackId || + operation.status !== "active" + ) { + throw new ManagedOperationOwnershipError(stackId); + } + return operation; + }; + + const transitionPortOwnership = ( + current: ManagedStackRecord | undefined, + next: ManagedStackRecord, + ): void => { + validateManagedPortAssignments(next.id, next.ports); + if (managedStackOccupiesPorts(next.lifecycle)) { + for (const assignment of next.ports) { + const owner = portOwners.get(assignment.port); + if (owner !== undefined && owner !== next.id) { + throw new ManagedPortReservationError(assignment.port, owner); + } + } + } + if (current !== undefined && managedStackOccupiesPorts(current.lifecycle)) { + for (const assignment of current.ports) { + if (portOwners.get(assignment.port) === current.id) { + portOwners.delete(assignment.port); + } + } + } + if (managedStackOccupiesPorts(next.lifecycle)) { + for (const assignment of next.ports) { + const owner = portOwners.get(assignment.port); + if (owner !== undefined && owner !== next.id) { + throw new ManagedPortReservationError(assignment.port, owner); + } + portOwners.set(assignment.port, next.id); + } + } + }; + + /** + * Tears an unpublished stack out of every index it was registered in and + * releases its claim, so the identity is immediately free to retry. Shared by + * the explicit abort path and by recovery's pending branch. + */ + const discardPendingStack = (stack: ManagedStackRecord, operationToken: string): void => { + transitionPortOwnership(stack, { ...stack, lifecycle: "stopped", ports: [] }); + stacks.delete(stack.id); + stackIdentities.delete(stackIdentityKey(stack.checkoutId, stack.contextId, stack.name)); + operations.delete(operationToken); + activeOperationByStack.delete(stack.id); + }; + + const claimOperation = (input: ClaimManagedOperationInput): ClaimManagedOperationResult => { + requireStack(input.stackId); + const activeToken = activeOperationByStack.get(input.stackId); + if (activeToken !== undefined) { + const active = operations.get(activeToken); + if (active !== undefined) { + return { acquired: false, operation: copy(active) }; + } + } + + const operation: ManagedOperationRecord = { + token: input.token, + stackId: input.stackId, + kind: input.kind, + status: "active", + ownerPid: input.ownerPid, + startedAt: input.now, + }; + operations.set(operation.token, operation); + activeOperationByStack.set(operation.stackId, operation.token); + return { acquired: true, operation: copy(operation) }; + }; + + return { + prepareOrdinaryStack(input) { + return atomic(() => { + projects.add(input.identity.projectId); + const checkout = checkouts.get(input.identity.checkoutId); + if (checkout !== undefined && checkout.projectId !== input.identity.projectId) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + checkout.projectId, + input.identity.projectId, + ); + } + checkouts.set(input.identity.checkoutId, { + id: input.identity.checkoutId, + projectId: input.identity.projectId, + }); + + const context = contexts.get(input.identity.contextId); + if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError( + input.identity.contextId, + context.checkoutId, + input.identity.checkoutId, + ); + } + contexts.set(input.identity.contextId, { + id: input.identity.contextId, + checkoutId: input.identity.checkoutId, + }); + + const existingLocation = [...locations.values()].find( + (location) => location.checkoutId === input.identity.checkoutId, + ); + if ( + existingLocation !== undefined && + existingLocation.canonicalPath !== input.canonicalPath + ) { + throw new DuplicateManagedIdentityError( + input.identity.checkoutId, + existingLocation.canonicalPath, + input.canonicalPath, + ); + } + const pathOwner = [...locations.values()].find( + (location) => location.canonicalPath === input.canonicalPath, + ); + if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError( + input.canonicalPath, + pathOwner.checkoutId, + input.identity.checkoutId, + ); + } + locations.set(existingLocation?.id ?? input.locationId, { + id: existingLocation?.id ?? input.locationId, + checkoutId: input.identity.checkoutId, + canonicalPath: input.canonicalPath, + lastSeenAt: input.now, + }); + + const identityKey = stackIdentityKey( + input.identity.checkoutId, + input.identity.contextId, + input.stackName, + ); + const existingStackId = stackIdentities.get(identityKey); + if (existingStackId !== undefined) { + const stack = requireStack(existingStackId); + const activeToken = activeOperationByStack.get(stack.id); + const operation = activeToken === undefined ? undefined : operations.get(activeToken); + return { + outcome: "existing", + stack: copy(stack), + operation: operation === undefined ? undefined : copy(operation), + }; + } + + const baseStack: ManagedStackRecord = { + id: input.stackId, + projectId: input.identity.projectId, + checkoutId: input.identity.checkoutId, + contextId: input.identity.contextId, + name: input.stackName, + status: "pending", + lifecycle: "stopped", + runtimeRequest: input.configuration.runtimeRequest ?? "auto", + runtime: input.configuration.runtime, + paths: input.paths, + ports: [], + serviceVersions: {}, + runtimeMetadata: emptyRuntimeMetadata(), + createdAt: input.now, + updatedAt: input.now, + }; + const stack = applyConfiguration(baseStack, input.configuration, input.now); + transitionPortOwnership(undefined, stack); + stacks.set(stack.id, stack); + stackIdentities.set(identityKey, stack.id); + const claimed = claimOperation({ + token: input.operationToken, + stackId: stack.id, + kind: "start", + ownerPid: input.ownerPid, + now: input.now, + }); + if (!claimed.acquired) { + throw new ManagedOperationOwnershipError(stack.id); + } + return { outcome: "create", stack: copy(stack), operation: claimed.operation }; + }); + }, + publishPendingStack(stackId, operationToken, now) { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { + ...current, + status: "active", + updatedAt: now, + }; + stacks.set(stackId, next); + const operation = operations.get(operationToken); + if (operation !== undefined) { + operations.set(operationToken, { + ...operation, + status: "completed", + finishedAt: now, + }); + } + activeOperationByStack.delete(stackId); + return copy(next); + }, + abortPendingStack(stackId, operationToken) { + requireOwnedOperation(stackId, operationToken); + const stack = requireStack(stackId); + if (stack.status !== "pending") { + throw new ManagedOperationOwnershipError(stackId); + } + discardPendingStack(stack, operationToken); + }, + getStack(stackId) { + const stack = stacks.get(stackId); + return stack === undefined ? undefined : copy(stack); + }, + listStacks(options) { + return [...stacks.values()] + .filter((stack) => options?.includeTombstoned === true || stack.status !== "tombstoned") + .sort( + (left, right) => + compareManagedText(left.createdAt, right.createdAt) || + compareManagedText(left.id, right.id), + ) + .map(copy); + }, + claimOperation, + finishOperation(stackId, operationToken, outcome, now, error) { + const operation = requireOwnedOperation(stackId, operationToken); + operations.set(operationToken, { + ...operation, + status: outcome, + finishedAt: now, + error, + }); + activeOperationByStack.delete(stackId); + }, + updateStack(input) { + requireOwnedOperation(input.stackId, input.operationToken); + const current = requireStack(input.stackId); + assertManagedStackUpdatable(current); + const next = applyConfiguration(current, input, input.now); + transitionPortOwnership(current, next); + stacks.set(current.id, next); + return copy(next); + }, + listActiveOperations(startedBefore) { + return [...activeOperationByStack.values()] + .flatMap((token) => { + const operation = operations.get(token); + return operation === undefined ? [] : [operation]; + }) + .filter((operation) => startedBefore === undefined || operation.startedAt < startedBefore) + .sort((left, right) => compareManagedText(left.startedAt, right.startedAt)) + .map(copy); + }, + reconcileOperation(stackId, operationToken, lifecycle, now) { + const operation = requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + if (current.status === "tombstoned") { + // A tombstoned row under a live claim is a deletion that died before + // releasing it. Registry state is already final, so recovery only + // releases the claim; reviving a lifecycle here would resurrect a + // deleted stack, and dropping the row would break idempotent deletion. + operations.set(operationToken, { + ...operation, + status: "failed", + finishedAt: now, + error: "Recovered after an abandoned deletion", + }); + activeOperationByStack.delete(stackId); + return { outcome: "tombstoned", stack: copy(current) }; + } + if (current.status === "pending" && lifecycle === "stopped") { + discardPendingStack(current, operationToken); + return { outcome: "discarded" }; + } + const next: ManagedStackRecord = { + ...current, + status: current.status === "pending" ? "active" : current.status, + lifecycle, + updatedAt: now, + }; + transitionPortOwnership(current, next); + stacks.set(stackId, next); + operations.set(operationToken, { + ...operation, + status: "failed", + finishedAt: now, + error: `Recovered after runtime reconciliation (${lifecycle})`, + }); + activeOperationByStack.delete(stackId); + return { outcome: "recovered", stack: copy(next) }; + }, + tombstoneStack(stackId, operationToken, now) { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { + ...current, + status: "tombstoned", + lifecycle: "stopped", + ports: [], + runtimeMetadata: emptyRuntimeMetadata(), + updatedAt: now, + tombstonedAt: now, + }; + transitionPortOwnership(current, next); + stacks.set(stackId, next); + stackIdentities.delete(stackIdentityKey(current.checkoutId, current.contextId, current.name)); + return copy(next); + }, + listCheckoutLocations() { + return [...locations.values()] + .sort((left, right) => compareManagedText(left.canonicalPath, right.canonicalPath)) + .map(copy); + }, + pruneCheckoutLocations(locationIds) { + let removed = 0; + for (const id of new Set(locationIds)) { + if (locations.delete(id)) { + removed += 1; + } + } + return removed; + }, + close() {}, + }; +}; diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index 54acdb4e51..ce70bd85d0 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -1,7 +1,6 @@ import { - DuplicateManagedIdentityError, InvalidManagedPortError, - ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, ManagedPortReservationError, ManagedRunningStackPortChangeError, ManagedStackNotFoundError, @@ -9,7 +8,6 @@ import { type ManagedOperationKind, type ManagedOperationRecord, type ManagedPortAssignment, - type ManagedRuntimeMetadata, type ManagedStackConfiguration, type ManagedStackLifecycle, type ManagedStackPaths, @@ -60,17 +58,24 @@ export interface UpdateManagedStackInput extends ManagedStackConfiguration { readonly now: string; } +/** + * How an abandoned operation was settled against observed runtime state. + * + * Recovery treats the three shapes differently: an adopted stack is reported as + * recovered, a discarded pending row frees its identity for a retry, and a + * tombstoned row means a crashed deletion — its registry state is already final + * and only the leaked stack directory still needs reclaiming. + */ +export type ReconcileManagedOperationResult = + | { readonly outcome: "recovered"; readonly stack: ManagedStackRecord } + | { readonly outcome: "discarded" } + | { readonly outcome: "tombstoned"; readonly stack: ManagedStackRecord }; + export interface ManagedStackRepository { - readonly kind: "in-memory" | "sqlite"; prepareOrdinaryStack(input: PrepareOrdinaryStackInput): PrepareOrdinaryStackResult; publishPendingStack(stackId: string, operationToken: string, now: string): ManagedStackRecord; abortPendingStack(stackId: string, operationToken: string): void; getStack(stackId: string): ManagedStackRecord | undefined; - getStackByIdentity( - checkoutId: string, - contextId: string, - stackName: string, - ): ManagedStackRecord | undefined; listStacks(options?: { readonly includeTombstoned?: boolean }): ReadonlyArray; claimOperation(input: ClaimManagedOperationInput): ClaimManagedOperationResult; finishOperation( @@ -87,31 +92,26 @@ export interface ManagedStackRepository { operationToken: string, lifecycle: ManagedStackLifecycle, now: string, - ): ManagedStackRecord | undefined; + ): ReconcileManagedOperationResult; tombstoneStack(stackId: string, operationToken: string, now: string): ManagedStackRecord; listCheckoutLocations(): ReadonlyArray; pruneCheckoutLocations(locationIds: ReadonlyArray): number; close(): void; } -interface InMemoryCheckout { - readonly id: string; - readonly projectId: string; -} - -interface InMemoryContext { - readonly id: string; - readonly checkoutId: string; -} - -const stackIdentityKey = (checkoutId: string, contextId: string, stackName: string): string => - `${checkoutId}\u0000${contextId}\u0000${stackName}`; - -const copy = (value: A): A => structuredClone(value); - export const managedStackOccupiesPorts = (lifecycle: ManagedStackLifecycle): boolean => lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping"; +/** + * Ordering shared by both adapters. SQLite compares TEXT with BINARY + * collation, so the in-memory repository must compare code points too: + * `localeCompare` folds case and would disagree on mixed-case paths. + */ +export const compareManagedText = (left: string, right: string): number => { + if (left < right) return -1; + return left > right ? 1 : 0; +}; + const portNumbersEqual = ( left: ReadonlyArray, right: ReadonlyArray, @@ -173,417 +173,21 @@ export const reconcileManagedPortAssignments = ( return reconciled; }; -const applyConfiguration = ( - stack: ManagedStackRecord, - configuration: ManagedStackConfiguration, - now: string, -): ManagedStackRecord => { - const lifecycle = configuration.lifecycle ?? stack.lifecycle; - return { - ...stack, - lifecycle, - runtimeRequest: configuration.runtimeRequest ?? stack.runtimeRequest, - runtime: configuration.runtime ?? stack.runtime, - ports: reconcileManagedPortAssignments(stack, configuration.ports, lifecycle), - serviceVersions: configuration.serviceVersions ?? stack.serviceVersions, - runtimeMetadata: configuration.runtimeMetadata ?? stack.runtimeMetadata, - configFingerprint: configuration.configFingerprint ?? stack.configFingerprint, - credentialsReference: configuration.credentialsReference ?? stack.credentialsReference, - updatedAt: now, - }; -}; - -const emptyRuntimeMetadata = (): ManagedRuntimeMetadata => ({ - processIds: {}, - containerIds: {}, -}); - -export const createInMemoryManagedStackRepository = (): ManagedStackRepository => { - const projects = new Set(); - const checkouts = new Map(); - const contexts = new Map(); - const locations = new Map(); - const stacks = new Map(); - const stackIdentities = new Map(); - const operations = new Map(); - const activeOperationByStack = new Map(); - const portOwners = new Map(); - - const atomic = (run: () => A): A => { - const snapshot = { - projects: structuredClone([...projects]), - checkouts: structuredClone([...checkouts]), - contexts: structuredClone([...contexts]), - locations: structuredClone([...locations]), - stacks: structuredClone([...stacks]), - stackIdentities: structuredClone([...stackIdentities]), - operations: structuredClone([...operations]), - activeOperationByStack: structuredClone([...activeOperationByStack]), - portOwners: structuredClone([...portOwners]), - }; - try { - return run(); - } catch (error: unknown) { - projects.clear(); - for (const project of snapshot.projects) projects.add(project); - checkouts.clear(); - for (const [key, value] of snapshot.checkouts) checkouts.set(key, value); - contexts.clear(); - for (const [key, value] of snapshot.contexts) contexts.set(key, value); - locations.clear(); - for (const [key, value] of snapshot.locations) locations.set(key, value); - stacks.clear(); - for (const [key, value] of snapshot.stacks) stacks.set(key, value); - stackIdentities.clear(); - for (const [key, value] of snapshot.stackIdentities) stackIdentities.set(key, value); - operations.clear(); - for (const [key, value] of snapshot.operations) operations.set(key, value); - activeOperationByStack.clear(); - for (const [key, value] of snapshot.activeOperationByStack) { - activeOperationByStack.set(key, value); - } - portOwners.clear(); - for (const [key, value] of snapshot.portOwners) portOwners.set(key, value); - throw error; - } - }; - - const requireStack = (stackId: string): ManagedStackRecord => { - const stack = stacks.get(stackId); - if (stack === undefined) { - throw new ManagedStackNotFoundError(stackId); - } - return stack; - }; - - const requireOwnedOperation = ( - stackId: string, - operationToken: string, - ): ManagedOperationRecord => { - const activeToken = activeOperationByStack.get(stackId); - const operation = operations.get(operationToken); - if ( - activeToken !== operationToken || - operation === undefined || - operation.stackId !== stackId || - operation.status !== "active" - ) { - throw new ManagedOperationOwnershipError(stackId); - } - return operation; - }; - - const transitionPortOwnership = ( - current: ManagedStackRecord | undefined, - next: ManagedStackRecord, - ): void => { - validateManagedPortAssignments(next.id, next.ports); - if (managedStackOccupiesPorts(next.lifecycle)) { - for (const assignment of next.ports) { - const owner = portOwners.get(assignment.port); - if (owner !== undefined && owner !== next.id) { - throw new ManagedPortReservationError(assignment.port, owner); - } - } - } - if (current !== undefined && managedStackOccupiesPorts(current.lifecycle)) { - for (const assignment of current.ports) { - if (portOwners.get(assignment.port) === current.id) { - portOwners.delete(assignment.port); - } - } - } - if (managedStackOccupiesPorts(next.lifecycle)) { - for (const assignment of next.ports) { - const owner = portOwners.get(assignment.port); - if (owner !== undefined && owner !== next.id) { - throw new ManagedPortReservationError(assignment.port, owner); - } - portOwners.set(assignment.port, next.id); - } - } - }; - - const claimOperation = (input: ClaimManagedOperationInput): ClaimManagedOperationResult => { - requireStack(input.stackId); - const activeToken = activeOperationByStack.get(input.stackId); - if (activeToken !== undefined) { - const active = operations.get(activeToken); - if (active !== undefined) { - return { acquired: false, operation: copy(active) }; - } - } - - const operation: ManagedOperationRecord = { - token: input.token, - stackId: input.stackId, - kind: input.kind, - status: "active", - ownerPid: input.ownerPid, - startedAt: input.now, - }; - operations.set(operation.token, operation); - activeOperationByStack.set(operation.stackId, operation.token); - return { acquired: true, operation: copy(operation) }; - }; - - return { - kind: "in-memory", - prepareOrdinaryStack(input) { - return atomic(() => { - projects.add(input.identity.projectId); - const checkout = checkouts.get(input.identity.checkoutId); - if (checkout !== undefined && checkout.projectId !== input.identity.projectId) { - throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - checkout.projectId, - input.identity.projectId, - ); - } - checkouts.set(input.identity.checkoutId, { - id: input.identity.checkoutId, - projectId: input.identity.projectId, - }); - - const context = contexts.get(input.identity.contextId); - if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { - throw new DuplicateManagedIdentityError( - input.identity.contextId, - context.checkoutId, - input.identity.checkoutId, - ); - } - contexts.set(input.identity.contextId, { - id: input.identity.contextId, - checkoutId: input.identity.checkoutId, - }); - - const existingLocation = [...locations.values()].find( - (location) => location.checkoutId === input.identity.checkoutId, - ); - if ( - existingLocation !== undefined && - existingLocation.canonicalPath !== input.canonicalPath - ) { - throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - existingLocation.canonicalPath, - input.canonicalPath, - ); - } - const pathOwner = [...locations.values()].find( - (location) => location.canonicalPath === input.canonicalPath, - ); - if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { - throw new DuplicateManagedIdentityError( - input.canonicalPath, - pathOwner.checkoutId, - input.identity.checkoutId, - ); - } - locations.set(existingLocation?.id ?? input.locationId, { - id: existingLocation?.id ?? input.locationId, - checkoutId: input.identity.checkoutId, - canonicalPath: input.canonicalPath, - lastSeenAt: input.now, - }); - - const identityKey = stackIdentityKey( - input.identity.checkoutId, - input.identity.contextId, - input.stackName, - ); - const existingStackId = stackIdentities.get(identityKey); - if (existingStackId !== undefined) { - const stack = requireStack(existingStackId); - const activeToken = activeOperationByStack.get(stack.id); - const operation = activeToken === undefined ? undefined : operations.get(activeToken); - return { - outcome: "existing", - stack: copy(stack), - operation: operation === undefined ? undefined : copy(operation), - }; - } - - const baseStack: ManagedStackRecord = { - id: input.stackId, - projectId: input.identity.projectId, - checkoutId: input.identity.checkoutId, - contextId: input.identity.contextId, - name: input.stackName, - status: "pending", - lifecycle: "stopped", - runtimeRequest: input.configuration.runtimeRequest ?? "auto", - runtime: input.configuration.runtime, - paths: input.paths, - ports: [], - serviceVersions: {}, - runtimeMetadata: emptyRuntimeMetadata(), - createdAt: input.now, - updatedAt: input.now, - }; - const stack = applyConfiguration(baseStack, input.configuration, input.now); - transitionPortOwnership(undefined, stack); - stacks.set(stack.id, stack); - stackIdentities.set(identityKey, stack.id); - const claimed = claimOperation({ - token: input.operationToken, - stackId: stack.id, - kind: "start", - ownerPid: input.ownerPid, - now: input.now, - }); - if (!claimed.acquired) { - throw new ManagedOperationOwnershipError(stack.id); - } - return { outcome: "create", stack: copy(stack), operation: claimed.operation }; - }); - }, - publishPendingStack(stackId, operationToken, now) { - requireOwnedOperation(stackId, operationToken); - const current = requireStack(stackId); - const next: ManagedStackRecord = { - ...current, - status: "active", - updatedAt: now, - }; - stacks.set(stackId, next); - const operation = operations.get(operationToken); - if (operation !== undefined) { - operations.set(operationToken, { - ...operation, - status: "completed", - finishedAt: now, - }); - } - activeOperationByStack.delete(stackId); - return copy(next); - }, - abortPendingStack(stackId, operationToken) { - requireOwnedOperation(stackId, operationToken); - const stack = requireStack(stackId); - if (stack.status !== "pending") { - throw new ManagedOperationOwnershipError(stackId); - } - transitionPortOwnership(stack, { ...stack, lifecycle: "stopped", ports: [] }); - stacks.delete(stackId); - stackIdentities.delete(stackIdentityKey(stack.checkoutId, stack.contextId, stack.name)); - operations.delete(operationToken); - activeOperationByStack.delete(stackId); - }, - getStack(stackId) { - const stack = stacks.get(stackId); - return stack === undefined ? undefined : copy(stack); - }, - getStackByIdentity(checkoutId, contextId, stackName) { - const stackId = stackIdentities.get(stackIdentityKey(checkoutId, contextId, stackName)); - if (stackId === undefined) { - return undefined; - } - const stack = stacks.get(stackId); - return stack === undefined ? undefined : copy(stack); - }, - listStacks(options) { - return [...stacks.values()] - .filter((stack) => options?.includeTombstoned === true || stack.status !== "tombstoned") - .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) - .map(copy); - }, - claimOperation, - finishOperation(stackId, operationToken, outcome, now, error) { - const operation = requireOwnedOperation(stackId, operationToken); - operations.set(operationToken, { - ...operation, - status: outcome, - finishedAt: now, - error, - }); - activeOperationByStack.delete(stackId); - }, - updateStack(input) { - requireOwnedOperation(input.stackId, input.operationToken); - const current = requireStack(input.stackId); - if (current.status === "tombstoned") { - // A tombstone is deleted state: a caller holding a stale ID must never - // resurrect it into a port-occupying lifecycle. - throw new ManagedStackNotFoundError(input.stackId); - } - const next = applyConfiguration(current, input, input.now); - transitionPortOwnership(current, next); - stacks.set(current.id, next); - return copy(next); - }, - listActiveOperations(startedBefore) { - return [...activeOperationByStack.values()] - .flatMap((token) => { - const operation = operations.get(token); - return operation === undefined ? [] : [operation]; - }) - .filter((operation) => startedBefore === undefined || operation.startedAt < startedBefore) - .sort((left, right) => left.startedAt.localeCompare(right.startedAt)) - .map(copy); - }, - reconcileOperation(stackId, operationToken, lifecycle, now) { - const operation = requireOwnedOperation(stackId, operationToken); - const current = requireStack(stackId); - if (current.status === "pending" && lifecycle === "stopped") { - transitionPortOwnership(current, { ...current, lifecycle: "stopped", ports: [] }); - stacks.delete(stackId); - stackIdentities.delete( - stackIdentityKey(current.checkoutId, current.contextId, current.name), - ); - operations.delete(operationToken); - activeOperationByStack.delete(stackId); - return undefined; - } - const next: ManagedStackRecord = { - ...current, - status: current.status === "pending" ? "active" : current.status, - lifecycle, - updatedAt: now, - }; - transitionPortOwnership(current, next); - stacks.set(stackId, next); - operations.set(operationToken, { - ...operation, - status: "failed", - finishedAt: now, - error: `Recovered after runtime reconciliation (${lifecycle})`, - }); - activeOperationByStack.delete(stackId); - return copy(next); - }, - tombstoneStack(stackId, operationToken, now) { - requireOwnedOperation(stackId, operationToken); - const current = requireStack(stackId); - const next: ManagedStackRecord = { - ...current, - status: "tombstoned", - lifecycle: "stopped", - ports: [], - runtimeMetadata: emptyRuntimeMetadata(), - updatedAt: now, - tombstonedAt: now, - }; - transitionPortOwnership(current, next); - stacks.set(stackId, next); - stackIdentities.delete(stackIdentityKey(current.checkoutId, current.contextId, current.name)); - return copy(next); - }, - listCheckoutLocations() { - return [...locations.values()] - .sort((left, right) => left.canonicalPath.localeCompare(right.canonicalPath)) - .map(copy); - }, - pruneCheckoutLocations(locationIds) { - let removed = 0; - for (const id of new Set(locationIds)) { - if (locations.delete(id)) { - removed += 1; - } - } - return removed; - }, - close() {}, - }; +/** + * The stack states `updateStack` refuses, shared so both adapters reject the + * same targets: + * + * - a tombstone is deleted state, and a caller holding a stale ID must never + * resurrect it into a port-occupying lifecycle; + * - a pending row is still owned by its publisher's provisioning flow, which + * publishes or aborts it as a whole. Reconfiguring it would hand a + * port-occupying lease to a stack no reader can see yet. + */ +export const assertManagedStackUpdatable = (stack: ManagedStackRecord): void => { + if (stack.status === "tombstoned") { + throw new ManagedStackNotFoundError(stack.id); + } + if (stack.status === "pending") { + throw new ManagedPendingStackUpdateError(stack.id); + } }; diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index 0d8cb64e4b..895b80b5ca 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -3,6 +3,7 @@ import { mkdir, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { DEFAULT_MANAGED_STACK_NAME, + InvalidManagedOwnerPidError, InvalidManagedStackNameError, ManagedAbandonedOperationError, ManagedOperationInProgressError, @@ -11,6 +12,7 @@ import { ManagedStackNotFoundError, ManagedStackNotStoppedError, ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, type ManagedCheckoutLocation, type ManagedOperationKind, type ManagedOperationRecord, @@ -27,6 +29,7 @@ import { } from "./identity.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; import { assertManagedStackRoot, managedStackPaths } from "./paths.ts"; +import { errorCode } from "./error-code.ts"; import type { ManagedStackRepository } from "./repository.ts"; export interface ManagedStackServiceOptions { @@ -111,6 +114,13 @@ export interface ManagedOperationRecoveryFailure { export interface ReconcileAbandonedOperationsResult { readonly recovered: ReadonlyArray; readonly abortedStackIds: ReadonlyArray; + /** + * Tombstoned stacks whose abandoned deletion recovery finished. The registry + * tombstone is deliberately preserved so repeated deletion stays idempotent; + * only the leaked stack directory was reclaimed, and a reclamation failure is + * reported under `failures` with the `state-reclamation` phase. + */ + readonly reclaimedStackIds: ReadonlyArray; readonly retained: ReadonlyArray; readonly skippedOperationIds: ReadonlyArray; readonly failures: ReadonlyArray; @@ -153,16 +163,19 @@ const selectionForStack = (stack: ManagedStackRecord): ManagedStackSelection => const wait = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); -const stackNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +/** Ceiling for {@link makeManagedStackService}'s publication poll backoff. */ +const MAX_PUBLICATION_POLL_MS = 250; -const errorCode = (error: unknown): string | undefined => { - if (typeof error !== "object" || error === null) { - return undefined; - } - const code = Reflect.get(error, "code"); - return typeof code === "string" ? code : undefined; -}; +const stackNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +/** + * Deliberately conservative: only a definite `ESRCH` proves the owner is gone, + * so a permission error (`EPERM`) keeps the claim rather than stealing it. It + * must never be asked about a value that is not a pid — `kill(0, 0)` signals + * the caller's own process group, and a fractional pid throws, either of which + * would report a dead owner as alive and wedge recovery forever. Callers + * therefore filter pids through {@link isUsableOwnerPid} first. + */ const processIsAlive = (pid: number): boolean => { try { process.kill(pid, 0); @@ -172,15 +185,38 @@ const processIsAlive = (pid: number): boolean => { } }; +const isUsableOwnerPid = (ownerPid: number): boolean => + Number.isSafeInteger(ownerPid) && ownerPid > 0; + +const requireOwnerPid = (ownerPid: number): number => { + if (!isUsableOwnerPid(ownerPid)) { + throw new InvalidManagedOwnerPidError(ownerPid); + } + return ownerPid; +}; + +/** + * The state root is a required option here, so a blank one is a caller bug + * rather than a request for the default: silently resolving it would anchor + * every managed path to the process' working directory. + */ +const requireManagedStateRoot = (stateRoot: string): string => { + const trimmed = stateRoot.trim(); + if (trimmed.length === 0) { + throw new UnsafeManagedStackPathError(stateRoot); + } + return resolve(trimmed); +}; + export const makeManagedStackService = ( options: ManagedStackServiceOptions, ): ManagedStackService => { // Anchored once, at the boundary: a relative root injected here would be // reinterpreted against the process' cwd at every later use. - const stateRoot = resolve(options.stateRoot); + const stateRoot = requireManagedStateRoot(options.stateRoot); const idFactory = options.idFactory ?? randomUUID; const clock = options.clock ?? (() => new Date()); - const ownerPid = options.ownerPid ?? process.pid; + const ownerPid = options.ownerPid === undefined ? process.pid : requireOwnerPid(options.ownerPid); const publicationTimeoutMs = options.publicationTimeoutMs ?? 10_000; const publicationPollMs = options.publicationPollMs ?? 10; const isProcessAlive = options.isProcessAlive ?? processIsAlive; @@ -256,6 +292,10 @@ export const makeManagedStackService = ( const awaitPublication = async (pending: ManagedStackRecord): Promise => { const deadline = performance.now() + publicationTimeoutMs; + // Publication normally lands within the first poll, so start tight and back + // off: a slow publisher must not be polled hundreds of times per second for + // the whole timeout window. + let pollMs = publicationPollMs; while (performance.now() <= deadline) { const current = options.repository.getStack(pending.id); if (current === undefined) { @@ -267,7 +307,8 @@ export const makeManagedStackService = ( if (current.status === "tombstoned") { throw new ManagedStackNotFoundError(current.id); } - await wait(publicationPollMs); + await wait(pollMs); + pollMs = Math.min(pollMs * 2, MAX_PUBLICATION_POLL_MS); } throw new ManagedStackPublicationTimeoutError(pending.id); }; @@ -475,6 +516,7 @@ export const makeManagedStackService = ( async reconcileAbandonedOperations(reconcileOptions) { const recovered: Array = []; const abortedStackIds: Array = []; + const reclaimedStackIds: Array = []; const retained: Array = []; const skippedOperationIds: Array = []; const failures: Array = []; @@ -494,7 +536,14 @@ export const makeManagedStackService = ( operation.token === forcedOperation.operationToken), ); for (const operation of operations) { - if (forcedOperation === undefined && operation.ownerPid !== undefined) { + // A persisted pid that is not a usable pid is treated as no owner at + // all: asking the liveness probe about it could report a live owner and + // wedge this claim forever, which is the failure recovery exists to fix. + if ( + forcedOperation === undefined && + operation.ownerPid !== undefined && + isUsableOwnerPid(operation.ownerPid) + ) { try { if (await isProcessAlive(operation.ownerPid)) { retained.push({ operation, reason: "owner-alive" }); @@ -530,8 +579,17 @@ export const makeManagedStackService = ( lifecycle, now(), ); - if (reconciled === undefined) { - abortedStackIds.push(stack.id); + if (reconciled.outcome === "recovered") { + recovered.push(reconciled.stack); + } else { + // Both remaining outcomes leave state on disk that no registry row + // will ever point at again: a discarded pending stack's partial + // provisioning, or the data a crashed deletion never got to remove. + if (reconciled.outcome === "discarded") { + abortedStackIds.push(stack.id); + } else { + reclaimedStackIds.push(stack.id); + } try { await removeStackState(stack); } catch (error: unknown) { @@ -542,8 +600,6 @@ export const makeManagedStackService = ( error, }); } - } else { - recovered.push(reconciled); } } catch (error: unknown) { if ( @@ -561,7 +617,14 @@ export const makeManagedStackService = ( }); } } - return { recovered, abortedStackIds, retained, skippedOperationIds, failures }; + return { + recovered, + abortedStackIds, + reclaimedStackIds, + retained, + skippedOperationIds, + failures, + }; }, async pruneCheckoutLocations(shouldPrune) { const stale: Array = []; diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index eb13e87519..d992c8ef66 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -26,13 +26,16 @@ import type { ManagedStackRepository, PrepareOrdinaryStackInput, PrepareOrdinaryStackResult, + ReconcileManagedOperationResult, UpdateManagedStackInput, } from "./repository.ts"; import { + assertManagedStackUpdatable, managedStackOccupiesPorts, reconcileManagedPortAssignments, validateManagedPortAssignments, } from "./repository.ts"; +import { errorCode } from "./error-code.ts"; type SqliteValue = null | number | string; @@ -161,16 +164,8 @@ const managedPortIntent = (value: string): ManagedPortIntent => { throw new Error(`Unknown managed port intent ${value}`); }; -const sqliteErrorCode = (error: unknown): string | undefined => { - if (typeof error !== "object" || error === null) { - return undefined; - } - const code = Reflect.get(error, "code"); - return typeof code === "string" ? code : undefined; -}; - const isSqliteBusy = (error: unknown): boolean => { - const code = sqliteErrorCode(error); + const code = errorCode(error); if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") { return true; } @@ -242,9 +237,6 @@ const initializeSchema = (database: ManagedSqliteDatabase): void => { CREATE TABLE contexts ( id TEXT PRIMARY KEY, checkout_id TEXT NOT NULL REFERENCES checkouts(id), - kind TEXT NOT NULL CHECK (kind IN ('workspace', 'branch', 'detached')), - locator TEXT, - status TEXT NOT NULL CHECK (status IN ('active', 'orphaned')), created_at TEXT NOT NULL ); @@ -330,6 +322,12 @@ const readTransaction = (database: ManagedSqliteDatabase, run: () => A): A => } }; +const decodePort = (row: unknown): ManagedPortAssignment => ({ + key: getString(row, "key"), + port: getNumber(row, "port"), + intent: managedPortIntent(getString(row, "intent")), +}); + const queryPorts = ( database: ManagedSqliteDatabase, stackId: string, @@ -337,13 +335,44 @@ const queryPorts = ( database .prepare("SELECT key, port, intent FROM ports WHERE stack_id = ? ORDER BY key") .all([stackId]) - .map((row) => ({ - key: getString(row, "key"), - port: getNumber(row, "port"), - intent: managedPortIntent(getString(row, "intent")), - })); + .map(decodePort); + +/** + * Ports for many stacks in one statement, so listing N stacks costs two queries + * instead of N + 1. + */ +const queryPortsByStack = ( + database: ManagedSqliteDatabase, + stackIds: ReadonlyArray, +): Map> => { + const byStack = new Map>(); + if (stackIds.length === 0) { + return byStack; + } + const placeholders = stackIds.map(() => "?").join(", "); + const rows = database + .prepare( + `SELECT stack_id, key, port, intent FROM ports + WHERE stack_id IN (${placeholders}) + ORDER BY stack_id, key`, + ) + .all([...stackIds]); + for (const row of rows) { + const stackId = getString(row, "stack_id"); + const assignments = byStack.get(stackId); + if (assignments === undefined) { + byStack.set(stackId, [decodePort(row)]); + continue; + } + assignments.push(decodePort(row)); + } + return byStack; +}; -const decodeStack = (database: ManagedSqliteDatabase, row: unknown): ManagedStackRecord => { +const decodeStackWithPorts = ( + row: unknown, + ports: ReadonlyArray, +): ManagedStackRecord => { const id = getString(row, "id"); const paths: ManagedStackPaths = { root: getString(row, "root_path"), @@ -362,7 +391,7 @@ const decodeStack = (database: ManagedSqliteDatabase, row: unknown): ManagedStac runtimeRequest: managedRuntimeRequest(getString(row, "runtime_request")), runtime: managedRuntime(getOptionalString(row, "runtime")), paths, - ports: queryPorts(database, id), + ports, serviceVersions: decodeStringRecord(parseJson(getString(row, "service_versions_json"))), runtimeMetadata: decodeRuntimeMetadata(parseJson(getString(row, "runtime_metadata_json"))), configFingerprint: getOptionalString(row, "config_fingerprint"), @@ -373,6 +402,9 @@ const decodeStack = (database: ManagedSqliteDatabase, row: unknown): ManagedStac }; }; +const decodeStack = (database: ManagedSqliteDatabase, row: unknown): ManagedStackRecord => + decodeStackWithPorts(row, queryPorts(database, getString(row, "id"))); + const decodeOperation = (row: unknown): ManagedOperationRecord => ({ token: getString(row, "token"), stackId: getString(row, "stack_id"), @@ -530,7 +562,6 @@ export const createSqliteManagedStackRepository = ( initializeSchema(database); return { - kind: "sqlite", prepareOrdinaryStack(input): PrepareOrdinaryStackResult { return transaction(database, () => { database @@ -568,11 +599,7 @@ export const createSqliteManagedStackRepository = ( ); } database - .prepare( - `INSERT OR IGNORE INTO contexts - (id, checkout_id, kind, locator, status, created_at) - VALUES (?, ?, 'workspace', NULL, 'active', ?)`, - ) + .prepare(`INSERT OR IGNORE INTO contexts (id, checkout_id, created_at) VALUES (?, ?, ?)`) .run([input.identity.contextId, input.identity.checkoutId, input.now]); const checkoutLocation = database @@ -672,17 +699,6 @@ export const createSqliteManagedStackRepository = ( getStack(stackId) { return readTransaction(database, () => getStack(database, stackId)); }, - getStackByIdentity(checkoutId, contextId, stackName) { - return readTransaction(database, () => { - const row = database - .prepare( - `SELECT * FROM stacks - WHERE checkout_id = ? AND context_id = ? AND name = ? AND status != 'tombstoned'`, - ) - .get([checkoutId, contextId, stackName]); - return row === undefined ? undefined : decodeStack(database, row); - }); - }, listStacks(options) { return readTransaction(database, () => { const rows = @@ -693,7 +709,13 @@ export const createSqliteManagedStackRepository = ( "SELECT * FROM stacks WHERE status != 'tombstoned' ORDER BY created_at, id", ) .all(); - return rows.map((row) => decodeStack(database, row)); + const portsByStack = queryPortsByStack( + database, + rows.map((row) => getString(row, "id")), + ); + return rows.map((row) => + decodeStackWithPorts(row, portsByStack.get(getString(row, "id")) ?? []), + ); }); }, claimOperation(input) { @@ -715,11 +737,7 @@ export const createSqliteManagedStackRepository = ( return transaction(database, () => { requireOwnedOperation(database, input.stackId, input.operationToken); const current = requireStack(database, input.stackId); - if (current.status === "tombstoned") { - // A tombstone is deleted state: a caller holding a stale ID must - // never resurrect it into a port-occupying lifecycle. - throw new ManagedStackNotFoundError(input.stackId); - } + assertManagedStackUpdatable(current); const runtimeRequest = input.runtimeRequest ?? current.runtimeRequest; const runtime = input.runtime ?? current.runtime; const lifecycle = input.lifecycle ?? current.lifecycle; @@ -765,13 +783,27 @@ export const createSqliteManagedStackRepository = ( .all([startedBefore]); return rows.map(decodeOperation); }, - reconcileOperation(stackId, operationToken, lifecycle, now) { + reconcileOperation(stackId, operationToken, lifecycle, now): ReconcileManagedOperationResult { return transaction(database, () => { requireOwnedOperation(database, stackId, operationToken); const current = requireStack(database, stackId); + if (current.status === "tombstoned") { + // A tombstoned row under a live claim is a deletion that died before + // releasing it. Registry state is already final, so recovery only + // releases the claim; reviving a lifecycle here would resurrect a + // deleted stack, and dropping the row would break idempotent deletion. + database + .prepare( + `UPDATE operations SET + status = 'failed', finished_at = ?, error = ? + WHERE token = ? AND stack_id = ?`, + ) + .run([now, "Recovered after an abandoned deletion", operationToken, stackId]); + return { outcome: "tombstoned", stack: current }; + } if (current.status === "pending" && lifecycle === "stopped") { database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); - return undefined; + return { outcome: "discarded" }; } replacePorts(database, stackId, current.ports, lifecycle); database @@ -794,7 +826,7 @@ export const createSqliteManagedStackRepository = ( operationToken, stackId, ]); - return requireStack(database, stackId); + return { outcome: "recovered", stack: requireStack(database, stackId) }; }); }, tombstoneStack(stackId, operationToken, now) { diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index 3a4ba58979..6d4d02e961 100644 --- a/packages/stack/src/testing.ts +++ b/packages/stack/src/testing.ts @@ -18,5 +18,5 @@ export { managedStackContractFixtures, } from "./managed-stack-contract.ts"; export { validateManagedStackContractFixtures } from "./managed-stack-contract-validation.ts"; -export { createInMemoryManagedStackRepository } from "./managed/repository.ts"; +export { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; export { UnixHttpClient } from "./UnixHttpClient.ts"; From 0b2aaa72668d681c6ef7574e79700860a6377f48 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 10:35:03 +0200 Subject: [PATCH 09/18] fix(stack): close managed recovery and parity gaps from delta review - filter corrupt owner pids on the provision liveness path - reclaim tombstoned deletions without requiring runtime inspection - fail fast on blank explicit state roots and honor configured poll intervals - report reclaimed stacks only after successful data removal - align port ordering, operation ordering, and owner-pid validation across adapters Co-Authored-By: Claude Fable 5 --- packages/stack/docs/architecture.md | 22 +- packages/stack/src/managed-paths.unit.test.ts | 16 +- .../src/managed-service.integration.test.ts | 311 +++++++++++++++++- packages/stack/src/managed/identity.ts | 9 +- packages/stack/src/managed/model.ts | 13 +- packages/stack/src/managed/paths.ts | 22 +- .../stack/src/managed/repository-memory.ts | 27 +- packages/stack/src/managed/repository.ts | 40 ++- packages/stack/src/managed/service.ts | 131 ++++---- packages/stack/src/managed/sqlite.ts | 16 +- 10 files changed, 497 insertions(+), 110 deletions(-) diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 2015cf8ea2..39be053121 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -287,7 +287,10 @@ branch on failures without requiring an Effect runtime at this persistence bound The managed surface owns a versioned SQLite registry with separate records for projects, checkouts, checkout locations, development contexts, stacks, port reservations, and operations. The public repository contract contains no SQLite types, so the same service runs with the -in-memory test repository and the Node or Bun persistent Adapter. +in-memory test repository and the Node or Bun persistent Adapter. Both adapters owe identical +observable semantics, so record ordering — port assignments by key, active operations by start time +then operation token — and input validation such as refusing an operation owner PID that could never +be probed live in shared helpers rather than in either adapter. For an ordinary non-Git folder, the first mutating managed operation atomically publishes: @@ -322,7 +325,9 @@ Schema v3 intentionally has no migration path for this unreleased POC. Before fi developers holding any earlier `registry-v*.sqlite3` must remove the old managed state root, including its shared `stacks/` directory; registry generations must not be kept side by side. The state root is required to be a non-empty path wherever it is passed explicitly, so a blank -value fails instead of silently anchoring managed state to the process' working directory. +value fails instead of silently anchoring managed state to the process' working directory. An +explicit root is a decision and a blank one is a caller bug; a blank environment value is instead +treated as unset and falls through to the next source. Recovery can also leave an unregistered UUID stack root when a provisioner writes after its pending row was concurrently aborted. The provision error reports the failed ownership cleanup, but there is no automatic orphan @@ -334,19 +339,26 @@ Concurrent callers resolve the published record rather than creating aliases. Re retains claims whose owner process is still alive. Once an owner is gone, runtime inspection either publishes a running pending stack or aborts a stopped pending stack so the same identity can retry. An abandoned claim over an already tombstoned row is a deletion that died before releasing it: -recovery finishes that deletion instead of reconciling a lifecycle. It never revives the row and +recovery finishes that deletion instead of reconciling a lifecycle, without consulting runtime +inspection at all. Tombstoning already zeroed the runtime metadata an inspector would read, so +requiring an answer there would retain every crashed deletion forever. It never revives the row and never drops the tombstone, since idempotent deletion depends on it; it releases the claim and reclaims the leaked stack directory, reporting a failed removal like any other reclamation failure. Reconciliation is therefore repeatable: a second pass over the same crashed deletion is a no-op. Ownership races are isolated per operation so one completed claim does not stop the recovery pass. PID liveness is deliberately conservative and assumes the managed root stays within one host PID -namespace. Because a PID is not a permanent process identity, callers can request forced recovery +namespace. A stored PID that is not a probeable PID counts as no owner at all, both when recovery +walks abandoned claims and when provision decides whether to wait for a publisher, since probing it +could report a dead owner as alive. Because a PID is not a permanent process identity, callers can request forced recovery after trustworthy runtime inspection; this is also the required integration path for a state root shared across PID namespaces. Forced recovery requires an exact stack ID and operation token, processes only that claim, and bypasses only its PID gate—never runtime inspection. Forced recovery and the `startedBefore` age filter are mutually exclusive. Recovery results distinguish live owners, unknown or failed liveness/runtime inspection, concurrent skips, reconciliation failures, -reclaimed tombstones from finished deletions, and post-abort data-reclamation failures. A failed reconciliation of an active stack marks its lifecycle +reclaimed tombstones from finished deletions, and post-abort data-reclamation failures. An aborted +or reclaimed stack ID is reported only after its leaked directory is actually removed; a failed +removal is reported solely as a data-reclamation failure, so the two lists never claim data is gone +while it is still on disk. A failed reconciliation of an active stack marks its lifecycle `failed` before best-effort claim release, preserving the requirement for an explicit stop path before deletion. A failed pending-stack adoption retains its claim so a later pass can retry without losing potentially live unpublished data. That claim blocks other mutations, including deletion, diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index f45c65766c..7d83a9bb4c 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -107,22 +107,28 @@ describe("managed paths", () => { ).toBe(join(resolve("relative/state"), "supabase", "managed")); }); - it("treats a blank explicit state root as unset", () => { + it("refuses a blank explicit state root instead of falling back", () => { // `resolve("")` silently yields the process' cwd, which would scatter // managed state across whatever directory the caller happened to run in. + // An explicit root is a decision, so a blank one is a caller bug rather + // than a request for the default — the same policy the service applies. for (const stateRoot of ["", " ", "\t"]) { - expect( + expect(() => resolveManagedStateRoot({ stateRoot, env: {}, homeDir: "/home/user", platform: "linux" }), - ).toBe("/home/user/.local/state/supabase/managed"); + ).toThrow(UnsafeManagedStackPathError); } - expect( + expect(() => resolveManagedStateRoot({ stateRoot: "", env: { SUPABASE_HOME: "/configured/supabase" }, homeDir: "/home/user", platform: "linux", }), - ).toBe("/configured/supabase/managed"); + ).toThrow(UnsafeManagedStackPathError); + }); + + it("names the blank root it refused instead of an empty message tail", () => { + expect(() => resolveManagedStateRoot({ stateRoot: "\t" })).toThrow(/"\\t"/); }); it("trims surrounding whitespace from an explicit state root", () => { diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 6b37d49ead..282e13b351 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -25,6 +25,7 @@ import { InvalidManagedIdentityError, MANAGED_REGISTRY_SCHEMA_VERSION, InvalidManagedOwnerPidError, + ManagedAbandonedOperationError, InvalidManagedPortError, InvalidManagedStackNameError, ManagedPendingStackUpdateError, @@ -507,6 +508,82 @@ describe("ordinary-folder managed stack contract", () => { service.close(); }); + it.each([0, -1, 1.5])( + "reports an abandoned claim instead of waiting on a corrupt stored owner pid %s", + async (ownerPid) => { + // A stored pid that is not a pid cannot be asked about: `kill(0, 0)` + // signals the caller's own process group and a fractional pid throws, + // either of which would report a dead owner as alive and make provision + // wait out the whole publication timeout for a publisher that is gone. + const root = makeRoot(); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + let livenessProbes = 0; + const corruptedRepository: ManagedStackRepository = { + ...repository, + prepareOrdinaryStack(input) { + const prepared = repository.prepareOrdinaryStack(input); + return prepared.outcome === "existing" && prepared.operation !== undefined + ? { ...prepared, operation: { ...prepared.operation, ownerPid } } + : prepared; + }, + }; + const service = makeManagedStackService({ + repository: corruptedRepository, + stateRoot: join(root, "managed"), + publicationTimeoutMs: 5_000, + publicationPollMs: 1, + isProcessAlive: () => { + livenessProbes += 1; + return true; + }, + }); + await prepareAbandonedStack(service, workspace, process.pid); + const startedAt = performance.now(); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedAbandonedOperationError); + + expect(livenessProbes).toBe(0); + expect(performance.now() - startedAt).toBeLessThan(1_000); + service.close(); + }, + ); + + it("keeps polling at a configured interval slower than the internal backoff ceiling", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + const pollTimes: Array = []; + const observedRepository: ManagedStackRepository = { + ...repository, + getStack(stackId) { + pollTimes.push(performance.now()); + return repository.getStack(stackId); + }, + }; + const service = makeManagedStackService({ + repository: observedRepository, + stateRoot: join(root, "managed"), + publicationTimeoutMs: 1_600, + publicationPollMs: 400, + isProcessAlive: () => true, + }); + await prepareAbandonedStack(service, workspace, process.pid); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedStackPublicationTimeoutError); + + // The backoff ceiling must never poll a publisher faster than the caller + // asked for; only the last wait may be shortened, by the deadline. + expect(pollTimes.length).toBeGreaterThanOrEqual(3); + const gaps = pollTimes.slice(1).map((time, index) => time - (pollTimes[index] ?? 0)); + expect(gaps.slice(0, 2).every((gap) => gap >= 350)).toBe(true); + service.close(); + }); + it("rejects a non-UUID stack factory result before deriving state paths", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); @@ -1125,7 +1202,9 @@ describe("managed repository and lifecycle", () => { inspectRuntime: async () => "stopped", }); - expect(reconciled.abortedStackIds).toEqual([pending.stack.id]); + // The claim is released and the pending row is gone, but the leaked data is + // still there, so the stack is reported as a reclamation failure only. + expect(reconciled.abortedStackIds).toEqual([]); expect(reconciled.failures).toEqual([ { operation: pending.operation, @@ -1556,12 +1635,13 @@ describe("managed repository and lifecycle", () => { } for (const adapter of ["in-memory", "bun-sqlite"] as const) { - for (const runtime of ["running", "stopped"] as const) { + for (const runtime of ["running", "stopped", "unknown"] as const) { it(`finishes a crashed delete without resurrecting its tombstone with ${adapter} (${runtime} runtime)`, async () => { // A tombstoned row under a claimed operation is a delete that died // between tombstoning and releasing its claim. Recovery must finish the // deletion, never revive the row into a lifecycle — whatever the - // runtime inspection reports about the dead owner's processes. + // runtime inspection reports about the dead owner's processes, + // including nothing at all. const root = makeRoot(); const overrides = { isProcessAlive: () => false }; const service = @@ -1627,6 +1707,231 @@ describe("managed repository and lifecycle", () => { } } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`reclaims a crashed delete without consulting the runtime with ${adapter}`, async () => { + // Tombstoning zeroes the runtime metadata, so a real inspector can only + // ever answer "unknown" — or fail — about a crashed deletion. Gating the + // reclamation on an answer the tombstone destroyed would leak the + // directory forever, and the tombstoned branch ignores the lifecycle. + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? makeInMemoryService(root, overrides) + : makePersistentService(root, overrides); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + writeFileSync(join(created.stack.paths.data, "database"), "leaked"); + const claimed = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_681, + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + throw new Error("Expected the delete operation to be claimed"); + } + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => { + throw new Error("runtime inspection is unavailable for a deleted stack"); + }, + }); + + expect(reconciled.reclaimedStackIds).toEqual([created.stack.id]); + expect(reconciled.retained).toEqual([]); + expect(reconciled.failures).toEqual([]); + expect(service.repository.listActiveOperations()).toEqual([]); + expect(existsSync(created.stack.paths.root)).toBe(false); + expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); + service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`reports a crashed delete as reclaimed only once its data is gone with ${adapter}`, async () => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + const outsideRoot = join(root, "outside"); + mkdirSync(outsideRoot, { recursive: true }); + writeFileSync(join(outsideRoot, "preserve"), "safe"); + const repository = + adapter === "in-memory" + ? createInMemoryManagedStackRepository() + : openBunSqliteManagedStackRepository(managedRegistryPath(stateRoot)); + let forgePath = false; + const guardedRepository: ManagedStackRepository = { + ...repository, + getStack(stackId) { + const stack = repository.getStack(stackId); + if (stack === undefined || !forgePath) { + return stack; + } + return { + ...stack, + paths: { + root: outsideRoot, + data: join(outsideRoot, "data"), + logs: join(outsideRoot, "logs"), + runtime: join(outsideRoot, "runtime"), + }, + }; + }, + }; + const service = makeManagedStackService({ + repository: guardedRepository, + stateRoot, + isProcessAlive: () => false, + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + const claimed = service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_682, + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + throw new Error("Expected the delete operation to be claimed"); + } + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ); + forgePath = true; + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + + // Reporting the stack as reclaimed before the removal succeeded would tell + // the caller its leaked data is gone while it is still on disk. + expect(reconciled.reclaimedStackIds).toEqual([]); + expect(reconciled.failures).toEqual([ + { + operation: claimed.operation, + phase: "state-reclamation", + operationReleased: true, + error: expect.any(UnsafeManagedStackPathError), + }, + ]); + expect(readFileSync(join(outsideRoot, "preserve"), "utf8")).toBe("safe"); + service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`stores port assignments in one canonical key order with ${adapter}`, async () => { + // SQLite reads ports back with `ORDER BY key`, so the shared reconciler + // must hand both adapters the same order or a caller's request order + // would leak into one adapter's records and not the other's. + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + const studio = { key: "studio.port", port: 55_501, intent: "exact" } as const; + const api = { key: "api.port", port: 55_502, intent: "exact" } as const; + const db = { key: "db.port", port: 55_503, intent: "exact" } as const; + const sorted = [api, db, studio]; + + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { ports: [studio, api, db] }, + }); + + expect(created.stack.ports).toEqual(sorted); + expect(service.inspectStack(created.stack.id)?.ports).toEqual(sorted); + + const updated = await service.updateStack(created.stack.id, { ports: [db, studio, api] }); + + expect(updated.ports).toEqual(sorted); + expect(service.inspectStack(created.stack.id)?.ports).toEqual(sorted); + service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`breaks active-operation ordering ties by token with ${adapter}`, async () => { + // Recovery walks this list, so two claims sharing one `startedAt` must not + // depend on insertion order: SQLite would return rowid order and the + // in-memory adapter its map order. Descending tokens make insertion order + // the wrong answer. + const root = makeRoot(); + const overrides = { clock: () => new Date("2026-08-11T00:00:00.000Z") }; + const service = + adapter === "in-memory" + ? makeInMemoryService(root, overrides) + : makePersistentService(root, overrides); + const nextToken = descendingIdFactory(); + const tokens: Array = []; + for (const name of ["first", "second", "third"]) { + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, name), + }); + const token = nextToken(); + const claimed = service.repository.claimOperation({ + token, + stackId: created.stack.id, + kind: "start", + ownerPid: 987_683, + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + throw new Error("Expected each recovery operation to be claimed"); + } + tokens.push(token); + } + + expect(tokens).toEqual([...tokens].sort().reverse()); + expect(service.repository.listActiveOperations().map(({ token }) => token)).toEqual( + [...tokens].sort(), + ); + service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`refuses to persist an unusable owner pid with ${adapter}`, async () => { + // The pid is only useful because recovery asks the operating system about + // it, and a value that is not a pid cannot be asked about safely. The + // repository is the boundary that must never store one. + const root = makeRoot(); + const service = + adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "claimed"), + }); + + for (const ownerPid of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid, + now: "2026-08-11T00:00:00.000Z", + }), + ).toThrow(InvalidManagedOwnerPidError); + } + expect(service.repository.listActiveOperations()).toEqual([]); + + await expect( + prepareAbandonedStack(service, makeWorkspace(root, "prepared"), 0), + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); + expect(service.listStacks()).toHaveLength(1); + service.close(); + }); + } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { it(`refuses to reconfigure an unpublished pending stack with ${adapter}`, async () => { // A pending row belongs to its publisher's provisioning flow. Letting a diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index d895a621ea..39f0a56cb2 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -7,16 +7,9 @@ import { type OrdinaryWorkspaceIdentity, } from "./model.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; +import { errorCode } from "./error-code.ts"; import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; -const errorCode = (error: unknown): string | undefined => { - if (typeof error !== "object" || error === null) { - return undefined; - } - const code = Reflect.get(error, "code"); - return typeof code === "string" ? code : undefined; -}; - const identityField = (value: unknown, field: string): string => { if (typeof value !== "object" || value === null) { throw new InvalidManagedIdentityError("The ordinary workspace identity must be an object"); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index 4bbd2f40bb..80352e78eb 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -237,8 +237,17 @@ export class ManagedRunningStackPortChangeError extends ManagedStackError { export class UnsafeManagedStackPathError extends ManagedStackError { readonly code = "UNSAFE_MANAGED_STACK_PATH"; - constructor(readonly path: string) { - super(`Refusing to remove an unsafe managed stack path: ${path}`); + /** + * The refused path is quoted rather than interpolated bare: the values worth + * refusing include blank and whitespace-only ones, which would otherwise + * render as an empty message tail. `reason` names which refusal this is, + * since the same coded failure guards both stack removal and state roots. + */ + constructor( + readonly path: string, + reason = "Refusing to remove an unsafe managed stack path", + ) { + super(`${reason}: ${JSON.stringify(path)}`); this.name = "UnsafeManagedStackPathError"; } } diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index 356f34c1a6..6872d0d4f5 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -15,6 +15,14 @@ const nonEmpty = (value: string | undefined): string | undefined => { return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; }; +const requireManagedStateRoot = (stateRoot: string): string => { + const trimmed = nonEmpty(stateRoot); + if (trimmed === undefined) { + throw new UnsafeManagedStackPathError(stateRoot, "Refusing a blank managed state root"); + } + return resolve(trimmed); +}; + /** * Every caller- or environment-supplied root is anchored to the working * directory once, here. A relative root would otherwise be reinterpreted @@ -23,14 +31,16 @@ const nonEmpty = (value: string | undefined): string | undefined => { * {@link assertManagedStackRoot} accept a same-shaped path under the new cwd. * `homedir()` is absolute by definition and needs no anchoring. * - * A blank explicit root is treated as unset rather than resolved: `resolve("")` - * silently yields the process' working directory, which would scatter managed - * state across whatever directory a caller happened to start in. + * An explicit root is a decision, so a blank one is a caller bug and fails + * rather than falling back: `resolve("")` silently yields the process' working + * directory, which would scatter managed state across whatever directory a + * caller happened to start in. Environment values are configuration that may + * legitimately be present but empty, so a blank one is treated as unset and + * falls through to the next source. */ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): string => { - const requested = nonEmpty(options.stateRoot); - if (requested !== undefined) { - return resolve(requested); + if (options.stateRoot !== undefined) { + return requireManagedStateRoot(options.stateRoot); } const env = options.env ?? process.env; diff --git a/packages/stack/src/managed/repository-memory.ts b/packages/stack/src/managed/repository-memory.ts index c1b41bc238..2683c70cde 100644 --- a/packages/stack/src/managed/repository-memory.ts +++ b/packages/stack/src/managed/repository-memory.ts @@ -10,6 +10,7 @@ import { type ManagedStackRecord, } from "./model.ts"; import { + assertManagedOwnerPid, assertManagedStackUpdatable, compareManagedText, managedStackOccupiesPorts, @@ -186,6 +187,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = }; const claimOperation = (input: ClaimManagedOperationInput): ClaimManagedOperationResult => { + assertManagedOwnerPid(input.ownerPid); requireStack(input.stackId); const activeToken = activeOperationByStack.get(input.stackId); if (activeToken !== undefined) { @@ -210,6 +212,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = return { prepareOrdinaryStack(input) { + assertManagedOwnerPid(input.ownerPid); return atomic(() => { projects.add(input.identity.projectId); const checkout = checkouts.get(input.identity.checkoutId); @@ -382,14 +385,22 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = return copy(next); }, listActiveOperations(startedBefore) { - return [...activeOperationByStack.values()] - .flatMap((token) => { - const operation = operations.get(token); - return operation === undefined ? [] : [operation]; - }) - .filter((operation) => startedBefore === undefined || operation.startedAt < startedBefore) - .sort((left, right) => compareManagedText(left.startedAt, right.startedAt)) - .map(copy); + return ( + [...activeOperationByStack.values()] + .flatMap((token) => { + const operation = operations.get(token); + return operation === undefined ? [] : [operation]; + }) + .filter((operation) => startedBefore === undefined || operation.startedAt < startedBefore) + // Recovery walks this list, so claims sharing one `startedAt` must not + // fall back to insertion order: the token breaks the tie in both adapters. + .sort( + (left, right) => + compareManagedText(left.startedAt, right.startedAt) || + compareManagedText(left.token, right.token), + ) + .map(copy) + ); }, reconcileOperation(stackId, operationToken, lifecycle, now) { const operation = requireOwnedOperation(stackId, operationToken); diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index ce70bd85d0..0f6ffd59b3 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -1,4 +1,5 @@ import { + InvalidManagedOwnerPidError, InvalidManagedPortError, ManagedPendingStackUpdateError, ManagedPortReservationError, @@ -102,6 +103,28 @@ export interface ManagedStackRepository { export const managedStackOccupiesPorts = (lifecycle: ManagedStackLifecycle): boolean => lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping"; +/** + * An operation's owner pid is only useful because recovery asks the operating + * system whether that process is still alive, and a value that is not a pid + * cannot be asked about: `kill(0, 0)` signals the caller's own process group + * and a fractional pid throws, either of which would report a dead owner as + * alive and wedge the claim forever. `undefined` is a valid answer — it records + * that no owner is known — so it is not usable, but it is not invalid either. + */ +export const isUsableManagedOwnerPid = (ownerPid: number | undefined): ownerPid is number => + ownerPid !== undefined && Number.isSafeInteger(ownerPid) && ownerPid > 0; + +/** + * Rejects a pid that could never be probed, at the boundary that would persist + * it. Shared so both adapters refuse the same inputs and no registry row can + * carry a pid that recovery cannot reason about. + */ +export const assertManagedOwnerPid = (ownerPid: number | undefined): void => { + if (ownerPid !== undefined && !isUsableManagedOwnerPid(ownerPid)) { + throw new InvalidManagedOwnerPidError(ownerPid); + } +}; + /** * Ordering shared by both adapters. SQLite compares TEXT with BINARY * collation, so the in-memory repository must compare code points too: @@ -157,12 +180,17 @@ export const reconcileManagedPortAssignments = ( } validateManagedPortAssignments(stack.id, requested); const persisted = new Map(stack.ports.map((assignment) => [assignment.key, assignment])); - const reconciled = requested.map((assignment) => { - const current = persisted.get(assignment.key); - return assignment.intent === "automatic" && current !== undefined - ? { ...assignment, port: current.port } - : assignment; - }); + // Sorted by key here, in the shared reconciler: SQLite reads its port rows + // back with `ORDER BY key`, so leaving the caller's request order in place + // would make the same request produce differently ordered records per adapter. + const reconciled = requested + .map((assignment) => { + const current = persisted.get(assignment.key); + return assignment.intent === "automatic" && current !== undefined + ? { ...assignment, port: current.port } + : assignment; + }) + .sort((left, right) => compareManagedText(left.key, right.key)); if ( managedStackOccupiesPorts(stack.lifecycle) && managedStackOccupiesPorts(targetLifecycle) && diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index 895b80b5ca..4654c7f73f 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -1,9 +1,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, rm } from "node:fs/promises"; -import { resolve } from "node:path"; import { DEFAULT_MANAGED_STACK_NAME, - InvalidManagedOwnerPidError, InvalidManagedStackNameError, ManagedAbandonedOperationError, ManagedOperationInProgressError, @@ -12,7 +10,6 @@ import { ManagedStackNotFoundError, ManagedStackNotStoppedError, ManagedStackPublicationTimeoutError, - UnsafeManagedStackPathError, type ManagedCheckoutLocation, type ManagedOperationKind, type ManagedOperationRecord, @@ -28,9 +25,13 @@ import { readOrdinaryWorkspaceIdentity, } from "./identity.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; -import { assertManagedStackRoot, managedStackPaths } from "./paths.ts"; +import { assertManagedStackRoot, managedStackPaths, resolveManagedStateRoot } from "./paths.ts"; import { errorCode } from "./error-code.ts"; -import type { ManagedStackRepository } from "./repository.ts"; +import { + assertManagedOwnerPid, + isUsableManagedOwnerPid, + type ManagedStackRepository, +} from "./repository.ts"; export interface ManagedStackServiceOptions { readonly repository: ManagedStackRepository; @@ -113,12 +114,18 @@ export interface ManagedOperationRecoveryFailure { export interface ReconcileAbandonedOperationsResult { readonly recovered: ReadonlyArray; + /** + * Discarded pending stacks whose leaked provisioning data was removed. A stack + * whose removal failed is reported under `failures` with the + * `state-reclamation` phase instead, never here: this list means the data is + * gone. + */ readonly abortedStackIds: ReadonlyArray; /** - * Tombstoned stacks whose abandoned deletion recovery finished. The registry + * Tombstoned stacks whose abandoned deletion recovery finished, with the same + * removal-succeeded guarantee as {@link abortedStackIds}. The registry * tombstone is deliberately preserved so repeated deletion stays idempotent; - * only the leaked stack directory was reclaimed, and a reclamation failure is - * reported under `failures` with the `state-reclamation` phase. + * only the leaked stack directory is reclaimed. */ readonly reclaimedStackIds: ReadonlyArray; readonly retained: ReadonlyArray; @@ -174,7 +181,7 @@ const stackNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; * must never be asked about a value that is not a pid — `kill(0, 0)` signals * the caller's own process group, and a fractional pid throws, either of which * would report a dead owner as alive and wedge recovery forever. Callers - * therefore filter pids through {@link isUsableOwnerPid} first. + * therefore filter pids through {@link isUsableManagedOwnerPid} first. */ const processIsAlive = (pid: number): boolean => { try { @@ -185,38 +192,20 @@ const processIsAlive = (pid: number): boolean => { } }; -const isUsableOwnerPid = (ownerPid: number): boolean => - Number.isSafeInteger(ownerPid) && ownerPid > 0; - -const requireOwnerPid = (ownerPid: number): number => { - if (!isUsableOwnerPid(ownerPid)) { - throw new InvalidManagedOwnerPidError(ownerPid); - } - return ownerPid; -}; - -/** - * The state root is a required option here, so a blank one is a caller bug - * rather than a request for the default: silently resolving it would anchor - * every managed path to the process' working directory. - */ -const requireManagedStateRoot = (stateRoot: string): string => { - const trimmed = stateRoot.trim(); - if (trimmed.length === 0) { - throw new UnsafeManagedStackPathError(stateRoot); - } - return resolve(trimmed); -}; - export const makeManagedStackService = ( options: ManagedStackServiceOptions, ): ManagedStackService => { - // Anchored once, at the boundary: a relative root injected here would be - // reinterpreted against the process' cwd at every later use. - const stateRoot = requireManagedStateRoot(options.stateRoot); + // Anchored and validated once, at the boundary, through the one resolver that + // owns state-root policy: a relative root injected here would be reinterpreted + // against the process' cwd at every later use, and a blank one would anchor + // every managed path to it. + const stateRoot = resolveManagedStateRoot({ stateRoot: options.stateRoot }); const idFactory = options.idFactory ?? randomUUID; const clock = options.clock ?? (() => new Date()); - const ownerPid = options.ownerPid === undefined ? process.pid : requireOwnerPid(options.ownerPid); + // Validated here as well as in the repository: the pid is this service's own + // option, so the failure belongs to the caller that supplied it. + assertManagedOwnerPid(options.ownerPid); + const ownerPid = options.ownerPid ?? process.pid; const publicationTimeoutMs = options.publicationTimeoutMs ?? 10_000; const publicationPollMs = options.publicationPollMs ?? 10; const isProcessAlive = options.isProcessAlive ?? processIsAlive; @@ -290,11 +279,15 @@ export const makeManagedStackService = ( return claimed.operation; }; + // Publication normally lands within the first poll, so start tight and back + // off: a slow publisher must not be polled hundreds of times per second for + // the whole timeout window. The ceiling only ever slows polling down, so a + // caller asking for a slower interval than the ceiling keeps its own. + const backOffPublicationPoll = (pollMs: number): number => + Math.min(pollMs * 2, Math.max(MAX_PUBLICATION_POLL_MS, publicationPollMs)); + const awaitPublication = async (pending: ManagedStackRecord): Promise => { const deadline = performance.now() + publicationTimeoutMs; - // Publication normally lands within the first poll, so start tight and back - // off: a slow publisher must not be polled hundreds of times per second for - // the whole timeout window. let pollMs = publicationPollMs; while (performance.now() <= deadline) { const current = options.repository.getStack(pending.id); @@ -307,8 +300,10 @@ export const makeManagedStackService = ( if (current.status === "tombstoned") { throw new ManagedStackNotFoundError(current.id); } - await wait(pollMs); - pollMs = Math.min(pollMs * 2, MAX_PUBLICATION_POLL_MS); + // Never sleep past the deadline: the timeout is the caller's bound, not a + // floor a long poll interval may overshoot by a whole interval. + await wait(Math.max(Math.min(pollMs, deadline - performance.now()), 0)); + pollMs = backOffPublicationPoll(pollMs); } throw new ManagedStackPublicationTimeoutError(pending.id); }; @@ -389,8 +384,12 @@ export const makeManagedStackService = ( if (prepared.operation === undefined) { throw new ManagedAbandonedOperationError(prepared.stack.id); } + // A stored pid that is not a usable pid means there is no owner to wait + // for, exactly as a missing one does: probing it could report a dead + // publisher as alive and make this caller wait out the whole + // publication timeout instead of reporting the abandoned claim. if ( - prepared.operation.ownerPid === undefined || + !isUsableManagedOwnerPid(prepared.operation.ownerPid) || !(await isProcessAlive(prepared.operation.ownerPid)) ) { throw new ManagedAbandonedOperationError(prepared.stack.id); @@ -539,11 +538,7 @@ export const makeManagedStackService = ( // A persisted pid that is not a usable pid is treated as no owner at // all: asking the liveness probe about it could report a live owner and // wedge this claim forever, which is the failure recovery exists to fix. - if ( - forcedOperation === undefined && - operation.ownerPid !== undefined && - isUsableOwnerPid(operation.ownerPid) - ) { + if (forcedOperation === undefined && isUsableManagedOwnerPid(operation.ownerPid)) { try { if (await isProcessAlive(operation.ownerPid)) { retained.push({ operation, reason: "owner-alive" }); @@ -561,18 +556,26 @@ export const makeManagedStackService = ( skippedOperationIds.push(operation.token); continue; } - let actual: "running" | "stopped" | "unknown"; - try { - actual = await reconcileOptions.inspectRuntime(stack, operation); - } catch (error: unknown) { - retained.push({ operation, reason: "runtime-inspection-failed", error }); - continue; - } - if (actual === "unknown") { - retained.push({ operation, reason: "runtime-unknown" }); - continue; + // A tombstoned row is a deletion that died before releasing its claim. + // Its registry state is already final, so `reconcileOperation` ignores + // the lifecycle for it — and tombstoning zeroed the runtime metadata an + // inspector would need, so asking could only answer "unknown" and leak + // the stack directory forever. + let lifecycle: ManagedStackLifecycle = "stopped"; + if (stack.status !== "tombstoned") { + let actual: "running" | "stopped" | "unknown"; + try { + actual = await reconcileOptions.inspectRuntime(stack, operation); + } catch (error: unknown) { + retained.push({ operation, reason: "runtime-inspection-failed", error }); + continue; + } + if (actual === "unknown") { + retained.push({ operation, reason: "runtime-unknown" }); + continue; + } + lifecycle = actual === "running" ? "running" : "stopped"; } - const lifecycle: ManagedStackLifecycle = actual === "running" ? "running" : "stopped"; const reconciled = options.repository.reconcileOperation( stack.id, operation.token, @@ -585,13 +588,15 @@ export const makeManagedStackService = ( // Both remaining outcomes leave state on disk that no registry row // will ever point at again: a discarded pending stack's partial // provisioning, or the data a crashed deletion never got to remove. - if (reconciled.outcome === "discarded") { - abortedStackIds.push(stack.id); - } else { - reclaimedStackIds.push(stack.id); - } + // The stack is reported under either id list only once that data is + // actually gone; otherwise the removal failure is the whole report. try { await removeStackState(stack); + if (reconciled.outcome === "discarded") { + abortedStackIds.push(stack.id); + } else { + reclaimedStackIds.push(stack.id); + } } catch (error: unknown) { failures.push({ operation, diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index d992c8ef66..d0a54cc67f 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -30,6 +30,7 @@ import type { UpdateManagedStackInput, } from "./repository.ts"; import { + assertManagedOwnerPid, assertManagedStackUpdatable, managedStackOccupiesPorts, reconcileManagedPortAssignments, @@ -490,8 +491,9 @@ const replacePorts = ( const claimOperation = ( database: ManagedSqliteDatabase, input: ClaimManagedOperationInput, -): ClaimManagedOperationResult => - transaction(database, () => { +): ClaimManagedOperationResult => { + assertManagedOwnerPid(input.ownerPid); + return transaction(database, () => { requireStack(database, input.stackId); const active = getActiveOperation(database, input.stackId); if (active !== undefined) { @@ -510,6 +512,7 @@ const claimOperation = ( } return { acquired: true, operation }; }); +}; const insertConfiguration = ( database: ManagedSqliteDatabase, @@ -563,6 +566,7 @@ export const createSqliteManagedStackRepository = ( return { prepareOrdinaryStack(input): PrepareOrdinaryStackResult { + assertManagedOwnerPid(input.ownerPid); return transaction(database, () => { database .prepare("INSERT OR IGNORE INTO projects (id, created_at) VALUES (?, ?)") @@ -770,15 +774,19 @@ export const createSqliteManagedStackRepository = ( }); }, listActiveOperations(startedBefore) { + // The token tie-break keeps claims that share one `startedAt` in a + // defined order instead of whatever order the sorter happens to emit. const rows = startedBefore === undefined ? database - .prepare("SELECT * FROM operations WHERE status = 'active' ORDER BY started_at") + .prepare( + "SELECT * FROM operations WHERE status = 'active' ORDER BY started_at, token", + ) .all() : database .prepare( `SELECT * FROM operations - WHERE status = 'active' AND started_at < ? ORDER BY started_at`, + WHERE status = 'active' AND started_at < ? ORDER BY started_at, token`, ) .all([startedBefore]); return rows.map(decodeOperation); From 81b719e2e01a42e80f9967126037d32614052b0e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 10:51:15 +0200 Subject: [PATCH 10/18] fix(stack): tolerate raced delete completion and require explicit state roots - treat a concurrently resolved delete claim as success after data removal - reject undefined state roots at the service boundary - document forced-recovery tombstone semantics and reclaim asymmetry - drop load-sensitive timing assertions from integration tests Co-Authored-By: Claude Fable 5 --- packages/stack/docs/architecture.md | 25 +++++--- .../src/managed-service.integration.test.ts | 63 ++++++++++++++++++- packages/stack/src/managed/service.ts | 31 ++++++++- 3 files changed, 106 insertions(+), 13 deletions(-) diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 39be053121..29f55219a4 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -351,14 +351,23 @@ namespace. A stored PID that is not a probeable PID counts as no owner at all, b walks abandoned claims and when provision decides whether to wait for a publisher, since probing it could report a dead owner as alive. Because a PID is not a permanent process identity, callers can request forced recovery after trustworthy runtime inspection; this is also the required integration path for a state root -shared across PID namespaces. Forced recovery requires an exact stack ID and operation token, -processes only that claim, and bypasses only its PID gate—never runtime inspection. Forced recovery -and the `startedBefore` age filter are mutually exclusive. Recovery results distinguish live owners, -unknown or failed liveness/runtime inspection, concurrent skips, reconciliation failures, -reclaimed tombstones from finished deletions, and post-abort data-reclamation failures. An aborted -or reclaimed stack ID is reported only after its leaked directory is actually removed; a failed -removal is reported solely as a data-reclamation failure, so the two lists never claim data is gone -while it is still on disk. A failed reconciliation of an active stack marks its lifecycle +shared across PID namespaces. Forced recovery requires an exact stack ID and operation token and +processes only that claim. It bypasses the PID gate, and tombstoned rows are reclaimed without +runtime inspection because tombstoning already cleared the runtime metadata an inspector would +read; forcing a claim whose owner is genuinely still finishing a delete can therefore race it—the +delete still completes and reports success, but the two processes may both attempt the same +directory removal. Forced recovery and the `startedBefore` age filter are mutually exclusive. +Recovery results distinguish live owners, unknown or failed liveness/runtime inspection, concurrent +skips, reconciliation failures, reclaimed tombstones from finished deletions, and post-abort +data-reclamation failures. An aborted or reclaimed stack ID is reported only after its leaked +directory is actually removed, so the two lists never claim data is gone while it is still on disk. +A failed removal is reported as a data-reclamation failure either way, but the two cases diverge +afterward: a reclaimed (tombstoned) stack's row survives in the registry, so its removal stays +retryable through ordinary `deleteStack` idempotency, while a discarded pending stack's row is +already gone by the time removal is attempted, so a failed removal leaves an orphaned directory +that is reported once and never revisited automatically—like any other orphan root, there is no +automatic garbage collection, so it requires manual cleanup. A failed reconciliation of an active +stack marks its lifecycle `failed` before best-effort claim release, preserving the requirement for an explicit stop path before deletion. A failed pending-stack adoption retains its claim so a later pass can retry without losing potentially live unpublished data. That claim blocks other mutations, including deletion, diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 282e13b351..a57bb3d889 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -539,14 +539,12 @@ describe("ordinary-folder managed stack contract", () => { }, }); await prepareAbandonedStack(service, workspace, process.pid); - const startedAt = performance.now(); await expect( service.provisionOrdinaryStack({ workspacePath: workspace }), ).rejects.toBeInstanceOf(ManagedAbandonedOperationError); expect(livenessProbes).toBe(0); - expect(performance.now() - startedAt).toBeLessThan(1_000); service.close(); }, ); @@ -578,7 +576,7 @@ describe("ordinary-folder managed stack contract", () => { // The backoff ceiling must never poll a publisher faster than the caller // asked for; only the last wait may be shortened, by the deadline. - expect(pollTimes.length).toBeGreaterThanOrEqual(3); + expect(pollTimes.length).toBeGreaterThanOrEqual(2); const gaps = pollTimes.slice(1).map((time, index) => time - (pollTimes[index] ?? 0)); expect(gaps.slice(0, 2).every((gap) => gap >= 350)).toBe(true); service.close(); @@ -619,6 +617,32 @@ describe("managed service options", () => { }, ); + it("refuses an undefined state root instead of falling back to SUPABASE_HOME or the home directory", () => { + // `stateRoot` is required in the option type, but a caller bypassing the + // type system (or a plain-JS caller) could still pass `undefined`. That + // must fail loudly instead of silently resolving against SUPABASE_HOME or + // the user's home directory. + const root = makeRoot(); + const configuredHome = join(root, "unused-supabase-home"); + const originalSupabaseHome = process.env["SUPABASE_HOME"]; + process.env["SUPABASE_HOME"] = configuredHome; + try { + expect(() => + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: undefined, + } as unknown as ManagedStackServiceOptions), + ).toThrow(UnsafeManagedStackPathError); + expect(existsSync(configuredHome)).toBe(false); + } finally { + if (originalSupabaseHome === undefined) { + delete process.env["SUPABASE_HOME"]; + } else { + process.env["SUPABASE_HOME"] = originalSupabaseHome; + } + } + }); + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( "refuses %s as an operation owner pid", (ownerPid) => { @@ -2031,6 +2055,39 @@ describe("managed repository and lifecycle", () => { expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); }); + it("treats a delete as successful when a concurrent forced recovery already resolved its operation", async () => { + // Data removal already happened by the time this call closes out the + // operation, so a concurrent forced recovery racing to resolve the same + // claim first must not turn an already-completed delete into a failure. + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + const racingRepository: ManagedStackRepository = { + ...repository, + finishOperation(stackId, operationToken, outcome, now, error) { + if (outcome === "completed") { + throw new ManagedOperationOwnershipError(stackId); + } + repository.finishOperation(stackId, operationToken, outcome, now, error); + }, + }; + const service = makeManagedStackService({ + repository: racingRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + + const deleted = await service.deleteStack(created.stack.id); + + expect(deleted).toMatchObject({ + outcome: "delete", + dataReclamation: { outcome: "removed" }, + }); + expect(existsSync(created.stack.paths.root)).toBe(false); + service.close(); + }); + it("stops, tombstones, and reclaims one opaque stack ID idempotently", async () => { const contract = fixture("reclamation.delete-repeat-is-idempotent"); const root = makeRoot(); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index 4654c7f73f..282f2b0980 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -10,6 +10,7 @@ import { ManagedStackNotFoundError, ManagedStackNotStoppedError, ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, type ManagedCheckoutLocation, type ManagedOperationKind, type ManagedOperationRecord, @@ -198,7 +199,16 @@ export const makeManagedStackService = ( // Anchored and validated once, at the boundary, through the one resolver that // owns state-root policy: a relative root injected here would be reinterpreted // against the process' cwd at every later use, and a blank one would anchor - // every managed path to it. + // every managed path to it. `stateRoot` is required in the option type, but a + // caller bypassing the type system (or a plain-JS caller) could still pass + // `undefined`, which would make `resolveManagedStateRoot` silently fall back + // to `SUPABASE_HOME`/the user's home directory instead of failing loudly. + if (options.stateRoot === undefined) { + throw new UnsafeManagedStackPathError( + String(options.stateRoot), + "Refusing to start a managed stack service without an explicit state root", + ); + } const stateRoot = resolveManagedStateRoot({ stateRoot: options.stateRoot }); const idFactory = options.idFactory ?? randomUUID; const clock = options.clock ?? (() => new Date()); @@ -241,6 +251,23 @@ export const makeManagedStackService = ( } }; + /** + * A concurrent forced recovery can resolve this same operation before this + * call closes it out, but only after the delete's own data removal already + * ran — so the delete is provably done and its ownership race must not be + * reported as a failure. Any other error still propagates, since only that + * specific race is known to be harmless. + */ + const finishDeleteOperationTolerantly = (stackId: string, operationToken: string): void => { + try { + options.repository.finishOperation(stackId, operationToken, "completed", now()); + } catch (error: unknown) { + if (!(error instanceof ManagedOperationOwnershipError)) { + throw error; + } + } + }; + const failRecoveryBestEffort = ( stack: ManagedStackRecord | undefined, operation: ManagedOperationRecord, @@ -505,7 +532,7 @@ export const makeManagedStackService = ( } const tombstoned = options.repository.tombstoneStack(stackId, operation.token, now()); const dataReclamation = await reclaimStackState(tombstoned); - options.repository.finishOperation(stackId, operation.token, "completed", now()); + finishDeleteOperationTolerantly(stackId, operation.token); return { outcome: "delete", stack: tombstoned, dataReclamation }; } catch (error: unknown) { finishOperationBestEffort(stackId, operation.token, error); From 908fc7eca194af284ea6adeccf7445c478150d96 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 11:11:19 +0200 Subject: [PATCH 11/18] fix(stack): restrict managed state permissions to the owning user - create the registry directory and stack state directories with mode 0o700 - chmod the registry database to 0o600 before WAL sidecars are created - assert owner-only modes in an integration test Co-Authored-By: Claude Fable 5 --- .../src/managed-service.integration.test.ts | 18 ++++++++++++++++++ packages/stack/src/managed/service.ts | 6 +++--- packages/stack/src/managed/sqlite-bun.ts | 10 ++++++++-- packages/stack/src/managed/sqlite-node.ts | 10 ++++++++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index a57bb3d889..6f0cf2cf31 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -7,6 +7,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -181,6 +182,23 @@ const prepareAbandonedStack = async ( }; describe("ordinary-folder managed stack contract", () => { + it("restricts registry and stack state permissions to the owning user", async () => { + const root = makeRoot(); + const service = makePersistentService(root); + const stateRoot = join(root, "managed"); + const { stack } = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + service.close(); + + const modeOf = (path: string): number => statSync(path).mode & 0o777; + expect(modeOf(stateRoot)).toBe(0o700); + expect(modeOf(managedRegistryPath(stateRoot))).toBe(0o600); + expect(modeOf(stack.paths.data)).toBe(0o700); + expect(modeOf(stack.paths.logs)).toBe(0o700); + expect(modeOf(stack.paths.runtime)).toBe(0o700); + }); + it("keeps read-only discovery registration-free", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index 282f2b0980..a4b226c300 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -434,9 +434,9 @@ export const makeManagedStackService = ( } try { - await mkdir(prepared.stack.paths.data, { recursive: true }); - await mkdir(prepared.stack.paths.logs, { recursive: true }); - await mkdir(prepared.stack.paths.runtime, { recursive: true }); + await mkdir(prepared.stack.paths.data, { recursive: true, mode: 0o700 }); + await mkdir(prepared.stack.paths.logs, { recursive: true, mode: 0o700 }); + await mkdir(prepared.stack.paths.runtime, { recursive: true, mode: 0o700 }); await provisionOptions.initialize?.(prepared.stack); await provisionOptions.validate?.(prepared.stack); const published = options.repository.publishPendingStack( diff --git a/packages/stack/src/managed/sqlite-bun.ts b/packages/stack/src/managed/sqlite-bun.ts index 7ce9019243..f12374e780 100644 --- a/packages/stack/src/managed/sqlite-bun.ts +++ b/packages/stack/src/managed/sqlite-bun.ts @@ -1,13 +1,19 @@ import { Database } from "bun:sqlite"; -import { mkdirSync } from "node:fs"; +import { chmodSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; export const openBunSqliteManagedStackRepository = (path: string) => { if (path !== ":memory:") { - mkdirSync(dirname(path), { recursive: true }); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } const database = new Database(path, { create: true }); + if (path !== ":memory:") { + // Restrict before the WAL conversion so the -wal/-shm sidecars inherit the + // owner-only mode; the registry stores workspace paths, ports, and + // credential references that other local users must not read. + chmodSync(path, 0o600); + } const adapter: ManagedSqliteDatabase = { exec(sql) { database.exec(sql); diff --git a/packages/stack/src/managed/sqlite-node.ts b/packages/stack/src/managed/sqlite-node.ts index 28f16262a8..61aaa21d9a 100644 --- a/packages/stack/src/managed/sqlite-node.ts +++ b/packages/stack/src/managed/sqlite-node.ts @@ -1,13 +1,19 @@ -import { mkdirSync } from "node:fs"; +import { chmodSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; export const openNodeSqliteManagedStackRepository = (path: string) => { if (path !== ":memory:") { - mkdirSync(dirname(path), { recursive: true }); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } const database = new DatabaseSync(path); + if (path !== ":memory:") { + // Restrict before the WAL conversion so the -wal/-shm sidecars inherit the + // owner-only mode; the registry stores workspace paths, ports, and + // credential references that other local users must not read. + chmodSync(path, 0o600); + } const adapter: ManagedSqliteDatabase = { exec(sql) { database.exec(sql); From e33bc31c6b1903b42d6b4e4e31faa4042af1ae69 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 11:41:48 +0200 Subject: [PATCH 12/18] fix(stack): create the managed registry with owner-only permissions atomically - pre-create the database file with mode 0o600 so it never exists with umask-derived permissions - retighten a registry file or directory left looser by an earlier build Co-Authored-By: Claude Fable 5 --- .../src/managed-service.integration.test.ts | 15 +++++++++++++++ packages/stack/src/managed/sqlite-bun.ts | 17 ++++++++++------- packages/stack/src/managed/sqlite-node.ts | 17 ++++++++++------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 6f0cf2cf31..36247cca22 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -199,6 +199,21 @@ describe("ordinary-folder managed stack contract", () => { expect(modeOf(stack.paths.runtime)).toBe(0o700); }); + it("retightens managed state permissions left loose by an earlier build", async () => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + const registryPath = managedRegistryPath(stateRoot); + mkdirSync(stateRoot, { recursive: true, mode: 0o755 }); + writeFileSync(registryPath, "", { mode: 0o644 }); + + const service = makePersistentService(root); + service.close(); + + const modeOf = (path: string): number => statSync(path).mode & 0o777; + expect(modeOf(stateRoot)).toBe(0o700); + expect(modeOf(registryPath)).toBe(0o600); + }); + it("keeps read-only discovery registration-free", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); diff --git a/packages/stack/src/managed/sqlite-bun.ts b/packages/stack/src/managed/sqlite-bun.ts index f12374e780..db57769cc8 100644 --- a/packages/stack/src/managed/sqlite-bun.ts +++ b/packages/stack/src/managed/sqlite-bun.ts @@ -1,19 +1,22 @@ import { Database } from "bun:sqlite"; -import { chmodSync, mkdirSync } from "node:fs"; +import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; import { dirname } from "node:path"; import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; export const openBunSqliteManagedStackRepository = (path: string) => { if (path !== ":memory:") { + // The registry stores workspace paths, ports, and credential references + // that other local users must not read. Pre-create the database file with + // an owner-only mode so it never exists with umask-derived permissions, + // and retighten both it and a directory left looser by an earlier build. + // Doing this before the WAL conversion also makes the -wal/-shm sidecars + // inherit the owner-only mode. mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - } - const database = new Database(path, { create: true }); - if (path !== ":memory:") { - // Restrict before the WAL conversion so the -wal/-shm sidecars inherit the - // owner-only mode; the registry stores workspace paths, ports, and - // credential references that other local users must not read. + chmodSync(dirname(path), 0o700); + closeSync(openSync(path, "a", 0o600)); chmodSync(path, 0o600); } + const database = new Database(path, { create: true }); const adapter: ManagedSqliteDatabase = { exec(sql) { database.exec(sql); diff --git a/packages/stack/src/managed/sqlite-node.ts b/packages/stack/src/managed/sqlite-node.ts index 61aaa21d9a..7a3b202815 100644 --- a/packages/stack/src/managed/sqlite-node.ts +++ b/packages/stack/src/managed/sqlite-node.ts @@ -1,19 +1,22 @@ -import { chmodSync, mkdirSync } from "node:fs"; +import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; export const openNodeSqliteManagedStackRepository = (path: string) => { if (path !== ":memory:") { + // The registry stores workspace paths, ports, and credential references + // that other local users must not read. Pre-create the database file with + // an owner-only mode so it never exists with umask-derived permissions, + // and retighten both it and a directory left looser by an earlier build. + // Doing this before the WAL conversion also makes the -wal/-shm sidecars + // inherit the owner-only mode. mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - } - const database = new DatabaseSync(path); - if (path !== ":memory:") { - // Restrict before the WAL conversion so the -wal/-shm sidecars inherit the - // owner-only mode; the registry stores workspace paths, ports, and - // credential references that other local users must not read. + chmodSync(dirname(path), 0o700); + closeSync(openSync(path, "a", 0o600)); chmodSync(path, 0o600); } + const database = new DatabaseSync(path); const adapter: ManagedSqliteDatabase = { exec(sql) { database.exec(sql); From 348e0a3c185c1ca656696f1052dde9299c7b91f2 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 12:32:37 +0200 Subject: [PATCH 13/18] refactor(stack): model managed errors as tagged errors - convert the managed error hierarchy to Data.TaggedError with stable codes - generate tag-keyed telemetry dispatch from the managed-model contract - replace the base class with a union type and runtime guard Co-Authored-By: Claude Fable 5 --- .../error-actionability-coverage.unit.test.ts | 41 ++- .../shared/telemetry/error-actionability.ts | 91 ++--- .../error-actionability.unit.test.ts | 28 +- packages/stack/docs/architecture.md | 9 +- packages/stack/src/entrypoints.unit.test.ts | 3 +- packages/stack/src/managed-model.unit.test.ts | 115 ++++-- .../src/managed-service.integration.test.ts | 2 +- packages/stack/src/managed/identity.ts | 26 +- packages/stack/src/managed/ids.ts | 2 +- packages/stack/src/managed/model.ts | 346 +++++++++++------- packages/stack/src/managed/paths.ts | 7 +- .../stack/src/managed/repository-memory.ts | 52 +-- packages/stack/src/managed/repository.ts | 12 +- packages/stack/src/managed/service.ts | 39 +- packages/stack/src/managed/sqlite.ts | 60 +-- 15 files changed, 514 insertions(+), 319 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 7d282a7848..2b556c8f29 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -10,7 +10,7 @@ declare global { readonly glob: (patterns: ReadonlyArray) => Record Promise>; } } -import { MANAGED_ERROR_CODES } from "@supabase/stack/managed-model"; +import { MANAGED_ERROR_CODES, MANAGED_ERROR_TAG_BY_CODE } from "@supabase/stack/managed-model"; import { CliErrorCategory, CliErrorKind, @@ -197,34 +197,51 @@ describe("workspace package error tags have external adapters", () => { } }); -// Managed failures are `class X extends ManagedStackError` and carry no `_tag`, -// so ERROR_DEFINITION_PATTERN only ever sees the `ManagedStackError` root and -// none of its subclasses. They also have no per-class adapter — the CLI -// dispatches them by their `code` literal — so the guard has to scan for the -// (class, code) pairs and check the code side instead of the class name. -const MANAGED_SUBCLASS_PATTERN = - /class\s+([A-Za-z0-9_]+)\s+extends\s+ManagedStackError\s*\{\s*readonly\s+code\s*=\s*"([A-Z0-9_]+)"/gs; +// Managed failures are tagged errors that also declare a stable `code`, and the +// CLI's dispatch table is generated from the package's tag/code map. The +// generic scan above already requires an adapter for each tag; this guard is +// what keeps the two halves of the contract joined — the (class, tag, code) +// triples in the model must agree with the exported map, the code list, and the +// code-keyed classification table. +const MANAGED_TAGGED_CLASS_PATTERN = + /class\s+([A-Za-z0-9_]+)\s+extends\s+Data\.TaggedError\(\s*"([A-Za-z0-9_]+)",?\s*\)[\s\S]*?readonly\s+code\s*=\s*"([A-Z0-9_]+)"/g; describe("managed registry error codes are classified", () => { it("packages/stack/src/managed/model.ts", () => { const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts"); - const matches = [...readFileSync(modelPath, "utf8").matchAll(MANAGED_SUBCLASS_PATTERN)]; - // One match per declared code: a subclass written in a shape this regex - // cannot see would otherwise pass vacuously instead of failing loudly. + const matches = [...readFileSync(modelPath, "utf8").matchAll(MANAGED_TAGGED_CLASS_PATTERN)]; + // One match per declared code: a class written in a shape this regex cannot + // see would otherwise pass vacuously instead of failing loudly. expect(matches.length).toBe(MANAGED_ERROR_CODES.length); const declaredCodes = new Set(MANAGED_ERROR_CODES); + const scannedCodes = new Set(); for (const match of matches) { const className = match[1] ?? ""; - const code = match[2] ?? ""; + const tag = match[2] ?? ""; + const code = match[3] ?? ""; + scannedCodes.add(code); + expect(tag, `${className} is tagged "${tag}" rather than its own export name`).toBe( + className, + ); expect( declaredCodes.has(code), `${className}'s code "${code}" is missing from MANAGED_ERROR_CODES`, ).toBe(true); + expect( + Reflect.get(MANAGED_ERROR_TAG_BY_CODE, code), + `MANAGED_ERROR_TAG_BY_CODE does not map "${code}" to ${className}`, + ).toBe(tag); expect( isClassifiedManagedErrorCode(code), `${className} ("${code}") has no entry in managedActionabilityByCode in error-actionability.ts`, ).toBe(true); + expect( + isClassifiedExternalErrorTag(tag), + `${className} ("${tag}") has no generated entry in externalActionabilityByTag in error-actionability.ts`, + ).toBe(true); } + // Every declared code is backed by a class, not just the other way round. + expect([...scannedCodes].sort()).toEqual([...declaredCodes].sort()); }); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index bcbd1ee723..a030f32265 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -1,4 +1,8 @@ -import type { ManagedErrorCode } from "@supabase/stack/managed-model"; +import { + MANAGED_ERROR_CODES, + MANAGED_ERROR_TAG_BY_CODE, + type ManagedErrorCode, +} from "@supabase/stack/managed-model"; import { Cause, Option } from "effect"; import type { CliError as EffectCliError } from "effect/unstable/cli"; @@ -771,12 +775,11 @@ const effectCliActionabilityByTag = { /** * `@supabase/stack` managed registry failures, keyed by the stable `code` - * literal each class declares. Every managed failure is a plain `Error` - * subclass of the single `ManagedStackError` root: none carries a `_tag`, and - * each subclass overwrites `name` with its own class name, so `code` is the - * only discriminator that both identifies the hierarchy and survives the - * identifier minification of release builds. This map is therefore the - * dispatch key as well as the classification table — see `classifyAtDepth`. + * literal each class declares. `code` is the package's wire-level contract: it + * survives the identifier minification of release builds, and Node/Bun callers + * outside an Effect runtime branch on it. Dispatch, however, goes through + * `_tag` like every other external error — {@link managedActionabilityByTag} + * projects this table onto the tags via the package's own tag/code map. * * Keyed by the package's exported {@link ManagedErrorCode} union, so the table * is exhaustive by construction: a new managed failure cannot be added in @@ -857,32 +860,35 @@ const managedActionabilityByCode: Record( - Object.entries(managedActionabilityByCode), +/** + * The managed table above, re-keyed by the `_tag` of the class that declares + * each code. Generated from `@supabase/stack`'s own tag/code map so the + * seventeen managed tags are classified without restating a single verdict: + * {@link managedActionabilityByCode} stays the one place a managed failure is + * classified, and a tag/code pair the package renames cannot silently fall + * through to `unknown`. + */ +const managedActionabilityByTag: Record = Object.fromEntries( + MANAGED_ERROR_CODES.map((code) => { + const declaration = managedActionabilityByCode[code]; + return [MANAGED_ERROR_TAG_BY_CODE[code], () => declaration]; + }), ); -function readManagedActionability( - error: ErrorRecord, -): CliErrorActionabilityDeclaration | undefined { - const code = readString(error, "code"); - return code === undefined ? undefined : managedActionabilityLookup.get(code); -} - /** * Whether a `@supabase/stack` managed error code has a classification in * {@link managedActionabilityByCode}. Used by the coverage test to keep the - * table exhaustive against the managed subclasses, which carry no `_tag` and - * therefore never reach {@link isClassifiedExternalErrorTag}. + * table exhaustive against the managed classes; the tags themselves are checked + * through {@link isClassifiedExternalErrorTag}, which the generated entries + * satisfy. */ export function isClassifiedManagedErrorCode(code: string): boolean { - return managedActionabilityLookup.has(code); + return Object.hasOwn(managedActionabilityByCode, code); } const externalActionabilityByTag: Record = { ...effectCliActionabilityByTag, + ...managedActionabilityByTag, // effect PlatformError — OS/filesystem operations. `reason` is // `BadArgument | SystemError`; BadArgument means the CLI itself passed a @@ -1025,13 +1031,6 @@ const externalActionabilityByTag: Record = { return { ...actionability.stopStack, fingerprint_suffix: "daemon_transport" }; }, - // @supabase/stack managed registry — see {@link managedActionabilityByCode}. - // Production dispatch never reaches this entry: `classifyAtDepth` routes - // managed failures by `code` (they carry no `_tag`). It stays because the - // hierarchy root is itself a plain Error subclass, which the coverage test's - // tag scan does pick up and requires an adapter for. - ManagedStackError: (error) => readManagedActionability(error) ?? actionability.unknown, - // @supabase/process-compose — the CLI generates the process graph, so graph // invariants are internal bugs; runtime service failures are stack-state // problems the user resolves by restarting the stack. @@ -1054,10 +1053,8 @@ export function isClassifiedExternalErrorTag(tag: string): boolean { /** * A wrapper's preserved `cause`, but only when classifying it cannot degrade - * the result: the cause must carry its own declaration, a known external - * adapter tag, or a recognized managed `code` (managed failures have neither a - * declaration nor a `_tag`), otherwise the wrapper's own classification is more - * truthful. + * the result: the cause must carry its own declaration or a known external + * adapter tag, otherwise the wrapper's own classification is more truthful. */ function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { const cause = error["cause"]; @@ -1065,7 +1062,6 @@ function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { if (readDeclaration(cause) !== undefined) return cause; const causeTag = readErrorTag(cause); if (causeTag !== undefined && Object.hasOwn(externalActionabilityByTag, causeTag)) return cause; - if (readManagedActionability(cause) !== undefined) return cause; return undefined; } @@ -1166,6 +1162,14 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { } } + // ManagedStackInitializationError is only a wrapper: the real provisioning + // failure (a Docker pull, a config parse, ...) is preserved in `cause`, and + // the generic initialization verdict would hide the actionable one. + if (isErrorRecord(error) && tag === "ManagedStackInitializationError") { + const cause = classifiableCause(error); + if (cause !== undefined) return classifyAtDepth(cause, depth + 1); + } + if (tag !== undefined && isErrorRecord(error)) { // Own-property lookup: a sanitized tag like "constructor" must not pick // up Object.prototype members as adapters. @@ -1199,25 +1203,6 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { } } - // Managed registry failures are untagged, and each subclass renames itself, - // so a recognized `code` literal is what routes them to their declaration. - if (isErrorRecord(error)) { - const managed = readManagedActionability(error); - if (managed !== undefined) { - // ManagedStackInitializationError is only a wrapper: the real - // provisioning failure (a Docker pull, a config parse, ...) is preserved - // in `cause`, and the generic initialization verdict would hide the - // actionable one. - if (readString(error, "code") === "MANAGED_STACK_INITIALIZATION_FAILED") { - const cause = classifiableCause(error); - if (cause !== undefined) return classifyAtDepth(cause, depth + 1); - } - // Everything else reports under the hierarchy root: the concrete failure - // is already carried by the declaration's fingerprint suffix. - return toActionability(managed, "error", "ManagedStackError"); - } - } - if (typeof error === "string") { return toActionability(actionability.unknown, "string", undefined); } diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 103b094223..5993ddebcc 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -598,10 +598,11 @@ describe("classifyCliErrorActionability", () => { expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); }); - // Managed registry errors are plain `Error` subclasses that rename themselves - // and carry no `_tag`, so `code` is the only thing routing them to their - // adapter. `managed-model.unit.test.ts` in `@supabase/stack` pins the real - // classes to the (name, code) pairs reproduced here. + // Managed registry errors are tagged errors that also declare a stable + // `code`: the tag routes them to an adapter generated from the package's + // tag/code map, and the code keys the verdict that adapter resolves. + // `managed-model.unit.test.ts` in `@supabase/stack` pins the real classes to + // the (tag, code) pairs reproduced here. it.each([ [ "InvalidManagedIdentityError", @@ -663,18 +664,20 @@ describe("classifyCliErrorActionability", () => { "managed_port_change", "invalid_config", ], - ])("classifies %s by its stable managed code", (name, code, suffix, category) => { + ])("classifies %s through its generated tag adapter", (tag, code, suffix, category) => { const error = new Error("managed registry failure"); - error.name = name; + error.name = tag; + Object.defineProperty(error, "_tag", { value: tag }); Object.defineProperty(error, "code", { value: code }); const result = classifyCliErrorActionability(error); expect(result.error_category).toBe(category); - expect(result.error_fingerprint).toBe(`error:ManagedStackError:${suffix}`); + expect(result.error_fingerprint).toBe(`tag:${tag}:${suffix}`); }); - it("leaves an unrecognized managed-shaped code unclassified", () => { + it("leaves an unregistered managed-shaped failure unclassified", () => { const unrecognized = new Error("managed failure"); unrecognized.name = "ManagedFutureError"; + Object.defineProperty(unrecognized, "_tag", { value: "ManagedFutureError" }); Object.defineProperty(unrecognized, "code", { value: "MANAGED_FUTURE_FAILURE" }); expect(classifyCliErrorActionability(unrecognized).error_kind).toBe("unknown"); }); @@ -684,6 +687,7 @@ describe("classifyCliErrorActionability", () => { it("classifies the provisioning cause of a managed initialization failure", () => { const wrapped = new Error("managed stack initialization failed"); wrapped.name = "ManagedStackInitializationError"; + Object.defineProperty(wrapped, "_tag", { value: "ManagedStackInitializationError" }); Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" }); Object.defineProperty(wrapped, "cause", { value: { _tag: "DockerPullError", image: "postgres", daemonDown: true }, @@ -700,17 +704,21 @@ describe("classifyCliErrorActionability", () => { it("falls back to the managed initialization verdict for an opaque cause", () => { const wrapped = new Error("managed stack initialization failed"); wrapped.name = "ManagedStackInitializationError"; + Object.defineProperty(wrapped, "_tag", { value: "ManagedStackInitializationError" }); Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" }); Object.defineProperty(wrapped, "cause", { value: { detail: "opaque" } }); const result = classifyCliErrorActionability(wrapped); expect(result.error_kind).toBe("user_actionable"); expect(result.suggested_command).toBe("supabase start"); - expect(result.error_fingerprint).toBe("error:ManagedStackError:managed_initialization"); + expect(result.error_fingerprint).toBe( + "tag:ManagedStackInitializationError:managed_initialization", + ); }); it("classifies a managed cause nested inside a stack wrapper", () => { const managed = new Error("port already reserved"); managed.name = "ManagedPortReservationError"; + Object.defineProperty(managed, "_tag", { value: "ManagedPortReservationError" }); Object.defineProperty(managed, "code", { value: "MANAGED_PORT_ALREADY_RESERVED" }); const result = classifyCliErrorActionability({ _tag: "StackBuildError", @@ -718,7 +726,7 @@ describe("classifyCliErrorActionability", () => { cause: managed, }); expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("error:ManagedStackError:port_conflict"); + expect(result.error_fingerprint).toBe("tag:ManagedPortReservationError:port_conflict"); }); it("classifies the preserved tagged cause of a StackError wrapper", () => { diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 29f55219a4..98f9761f89 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -281,8 +281,13 @@ Here, **managed state** means the centralized registry API exposed from `managed-stack.ts`, which remains part of the legacy Effect daemon surface. The registry API uses Promises because its consumers perform short filesystem and SQLite coordination around the Promise-oriented `createStack()` boundary; the runtime lifecycle beneath it remains Effect-based. -Its errors are ordinary `Error` subclasses with stable `code` fields so Node and Bun callers can -branch on failures without requiring an Effect runtime at this persistence boundary. +Its errors are `Data.TaggedError` classes carrying stable `code` fields, and there is no shared base +class: `ManagedStackError` is a union type over the seventeen failures, with `isManagedStackError` +as the runtime guard. `_tag` is the Effect-native discriminant, so an Effect consumer can +`catchTag` them directly; `code` is the wire-level contract that survives identifier minification, +so Node and Bun callers — and the CLI's telemetry classifier — can branch on failures without +requiring an Effect runtime at this persistence boundary. `MANAGED_ERROR_TAG_BY_CODE` links the two +so a consumer keying a table by one and dispatching on the other cannot drift. The managed surface owns a versioned SQLite registry with separate records for projects, checkouts, checkout locations, development contexts, stacks, port reservations, and operations. diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index ac9f142064..d41675a9f8 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -79,6 +79,7 @@ describe("@supabase/stack entrypoints", () => { "InvalidManagedPortError", "InvalidManagedStackNameError", "MANAGED_ERROR_CODES", + "MANAGED_ERROR_TAG_BY_CODE", "MANAGED_REGISTRY_SCHEMA_VERSION", "ManagedAbandonedOperationError", "ManagedOperationInProgressError", @@ -86,7 +87,6 @@ describe("@supabase/stack entrypoints", () => { "ManagedPendingStackUpdateError", "ManagedPortReservationError", "ManagedRunningStackPortChangeError", - "ManagedStackError", "ManagedStackInitializationError", "ManagedStackNotFoundError", "ManagedStackNotStoppedError", @@ -100,6 +100,7 @@ describe("@supabase/stack entrypoints", () => { "createManagedStackService", "createManagedUuid", "ensureOrdinaryWorkspaceIdentity", + "isManagedStackError", "makeManagedStackService", "managedRegistryPath", "managedStackPaths", diff --git a/packages/stack/src/managed-model.unit.test.ts b/packages/stack/src/managed-model.unit.test.ts index 277ed0c0b2..c104ca59e0 100644 --- a/packages/stack/src/managed-model.unit.test.ts +++ b/packages/stack/src/managed-model.unit.test.ts @@ -1,60 +1,123 @@ import { describe, expect, it } from "vitest"; import * as model from "./managed/model.ts"; -import { MANAGED_ERROR_CODES, ManagedStackError } from "./managed/model.ts"; +import { + MANAGED_ERROR_CODES, + MANAGED_ERROR_TAG_BY_CODE, + isManagedStackError, +} from "./managed/model.ts"; interface ManagedErrorCase { readonly exportName: string; readonly error: Error; readonly code: unknown; + readonly tag: unknown; } /** - * Every exported strict subclass of {@link ManagedStackError}, discovered by - * reflection rather than by hand: a subclass added without a registered code - * must fail here instead of silently classifying as `unknown` downstream. - * `prototype instanceof ManagedStackError` is false for the root itself, which - * is exactly the set we want. Each class is probed with placeholder - * constructor arguments because `code` is a field initializer rather than a - * parameter: only the message interpolation reads them. + * A single fields bag covering every field name declared across the managed + * failures. Each class ignores the members it does not declare, so one probe + * constructs them all: `code` is a field initializer, and only the message + * getters read the fields. Values are supplied rather than left absent so a + * getter that reaches into a structured field (the in-progress operation + * record) stays evaluable; a class introducing a new field name constructs with + * that field missing and fails loudly here rather than silently. */ -const CONSTRUCTOR_PROBE = [{}, {}, {}]; +const CONSTRUCTOR_PROBE = { + message: "probe", + found: 0, + supported: 0, + identityId: "probe", + existingClaim: "probe", + requestedClaim: "probe", + stackName: "probe", + ownerPid: 0, + port: 0, + key: "probe", + stackId: "probe", + operation: { kind: "start" }, + ownerStackId: "probe", + path: "probe", + cleanupErrors: [], +}; +/** + * Every error class exported from `./managed/model.ts`, discovered by + * reflection rather than by hand: the module declares nothing but managed + * failures, so an exported `Error` class that is missing from the code list or + * the tag map must fail here instead of silently classifying as `unknown` + * downstream. Discovery deliberately does not use {@link isManagedStackError} — + * that guard reads the tag map, which is one of the things under test. + */ const managedErrorCases: ReadonlyArray = Object.entries(model).flatMap( ([exportName, value]) => { if (typeof value !== "function") return []; const prototype: unknown = value.prototype; if (typeof prototype !== "object" || prototype === null) return []; - if (!(prototype instanceof ManagedStackError)) return []; - const error: unknown = Reflect.construct(value, CONSTRUCTOR_PROBE); + if (!(prototype instanceof Error)) return []; + const error: unknown = Reflect.construct(value, [CONSTRUCTOR_PROBE]); if (!(error instanceof Error)) return []; - return [{ exportName, error, code: Reflect.get(error, "code") }]; + return [ + { + exportName, + error, + code: Reflect.get(error, "code"), + tag: Reflect.get(error, "_tag"), + }, + ]; }, ); /** - * Consumers cannot discriminate managed failures by class: they are plain - * `Error` subclasses with no tag, and identifier minification renames the - * constructors. The CLI's telemetry classifier therefore dispatches on `code` - * (`apps/cli/src/shared/telemetry/error-actionability.ts`), so these literals - * are a published contract rather than an implementation detail. + * Managed failures are `Data.TaggedError` classes: `_tag` is the Effect-native + * discriminant, and `code` is the wire-level contract. Identifier minification + * renames the constructors but touches neither, and the CLI's telemetry + * classifier dispatches on the tag while keying its table by the code + * (`apps/cli/src/shared/telemetry/error-actionability.ts`), so both literals + * and the mapping between them are published contracts rather than + * implementation details. */ describe("managed error contract", () => { - it("keeps MANAGED_ERROR_CODES exhaustive against the exported subclasses", () => { + it("keeps MANAGED_ERROR_CODES exhaustive against the exported classes", () => { expect(managedErrorCases.length).toBe(MANAGED_ERROR_CODES.length); expect(managedErrorCases.map(({ code }) => code).sort()).toEqual( [...MANAGED_ERROR_CODES].sort(), ); }); - it.each(managedErrorCases)("exposes a stable code and class name on $exportName", (testCase) => { - expect(testCase.error).toBeInstanceOf(ManagedStackError); - expect(typeof testCase.code).toBe("string"); - expect(MANAGED_ERROR_CODES).toContain(testCase.code); - expect(testCase.error.name).toBe(testCase.exportName); - expect(testCase.error).not.toHaveProperty("_tag"); - }); + it.each(managedErrorCases)( + "exposes a stable code, tag and class name on $exportName", + (testCase) => { + expect(isManagedStackError(testCase.error)).toBe(true); + expect(typeof testCase.code).toBe("string"); + expect(MANAGED_ERROR_CODES).toContain(testCase.code); + expect(testCase.tag).toBe(testCase.exportName); + // `Data.TaggedError` installs the literal tag as a `name` data property + // on the generated base prototype, so `error.name` keeps reporting the + // class name — including in minified release builds, where + // `constructor.name` is renamed. + expect(testCase.error.name).toBe(testCase.exportName); + }, + ); - it.each([...MANAGED_ERROR_CODES])("declares %s on exactly one subclass", (code) => { + it.each([...MANAGED_ERROR_CODES])("declares %s on exactly one class", (code) => { expect(managedErrorCases.filter((testCase) => testCase.code === code)).toHaveLength(1); }); + + it("maps every code to the tag of the class declaring it", () => { + expect(Object.keys(MANAGED_ERROR_TAG_BY_CODE).sort()).toEqual([...MANAGED_ERROR_CODES].sort()); + for (const { code, tag } of managedErrorCases) { + expect(Reflect.get(MANAGED_ERROR_TAG_BY_CODE, String(code))).toBe(tag); + } + }); + + it("recognizes managed failures without a shared base class", () => { + expect(isManagedStackError(new model.ManagedStackNotFoundError({ stackId: "stack" }))).toBe( + true, + ); + expect(isManagedStackError(new Error("plain"))).toBe(false); + // A bare structural lookalike is not a managed failure: the guard requires + // a real Error so it cannot promote arbitrary payloads. + expect(isManagedStackError({ _tag: "ManagedStackNotFoundError" })).toBe(false); + expect(isManagedStackError(undefined)).toBe(false); + }); }); diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 36247cca22..973c76cc2f 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -2098,7 +2098,7 @@ describe("managed repository and lifecycle", () => { ...repository, finishOperation(stackId, operationToken, outcome, now, error) { if (outcome === "completed") { - throw new ManagedOperationOwnershipError(stackId); + throw new ManagedOperationOwnershipError({ stackId }); } repository.finishOperation(stackId, operationToken, outcome, now, error); }, diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index 39f0a56cb2..466c58e2a7 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -12,11 +12,13 @@ import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; const identityField = (value: unknown, field: string): string => { if (typeof value !== "object" || value === null) { - throw new InvalidManagedIdentityError("The ordinary workspace identity must be an object"); + throw new InvalidManagedIdentityError({ + message: "The ordinary workspace identity must be an object", + }); } const fieldValue = Reflect.get(value, field); if (typeof fieldValue !== "string") { - throw new InvalidManagedIdentityError(`${field} must be an opaque UUID`); + throw new InvalidManagedIdentityError({ message: `${field} must be an opaque UUID` }); } return assertManagedUuid(fieldValue, field); }; @@ -26,16 +28,20 @@ const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { try { value = JSON.parse(content); } catch (cause: unknown) { - throw new InvalidManagedIdentityError(`The ordinary workspace identity is not JSON: ${cause}`); + throw new InvalidManagedIdentityError({ + message: `The ordinary workspace identity is not JSON: ${cause}`, + }); } if (typeof value !== "object" || value === null) { - throw new InvalidManagedIdentityError("The ordinary workspace identity must be an object"); + throw new InvalidManagedIdentityError({ + message: "The ordinary workspace identity must be an object", + }); } const version = Reflect.get(value, "version"); if (version !== ORDINARY_WORKSPACE_IDENTITY_VERSION) { - throw new InvalidManagedIdentityError( - `Unsupported ordinary workspace identity version ${String(version)}`, - ); + throw new InvalidManagedIdentityError({ + message: `Unsupported ordinary workspace identity version ${String(version)}`, + }); } return { version, @@ -48,7 +54,7 @@ const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { export const canonicalizeOrdinaryWorkspacePath = async (workspacePath: string): Promise => { const info = await stat(workspacePath); if (!info.isDirectory()) { - throw new InvalidManagedIdentityError(`${workspacePath} is not a directory`); + throw new InvalidManagedIdentityError({ message: `${workspacePath} is not a directory` }); } return realpath(workspacePath); }; @@ -102,7 +108,9 @@ export const ensureOrdinaryWorkspaceIdentity = async ( } const winner = await readOrdinaryWorkspaceIdentity(workspacePath); if (winner === undefined) { - throw new InvalidManagedIdentityError("Identity publication raced without a winning marker"); + throw new InvalidManagedIdentityError({ + message: "Identity publication raced without a winning marker", + }); } return { identity: winner, created: false, markerPath }; } finally { diff --git a/packages/stack/src/managed/ids.ts b/packages/stack/src/managed/ids.ts index 96af6acad8..0f40ed2baa 100644 --- a/packages/stack/src/managed/ids.ts +++ b/packages/stack/src/managed/ids.ts @@ -4,7 +4,7 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3 export const assertManagedUuid = (value: string, label: string): string => { if (!UUID_PATTERN.test(value)) { - throw new InvalidManagedIdentityError(`${label} must be an opaque UUID`); + throw new InvalidManagedIdentityError({ message: `${label} must be an opaque UUID` }); } return value; }; diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index 80352e78eb..c75851f053 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -1,3 +1,5 @@ +import { Data } from "effect"; + export const MANAGED_REGISTRY_SCHEMA_VERSION = 3; export const ORDINARY_WORKSPACE_IDENTITY_VERSION = 1; export const DEFAULT_MANAGED_STACK_NAME = "default"; @@ -95,204 +97,250 @@ export interface ManagedStackSelection { readonly stackName: string; } -export class ManagedStackError extends Error {} - -export class InvalidManagedIdentityError extends ManagedStackError { - readonly code = "INVALID_MANAGED_IDENTITY"; - - constructor(message: string) { - super(message); - this.name = "InvalidManagedIdentityError"; - } +export class InvalidManagedIdentityError extends Data.TaggedError("InvalidManagedIdentityError")<{ + readonly message: string; +}> { + readonly code = "INVALID_MANAGED_IDENTITY" as const; } -export class UnsupportedManagedRegistryVersionError extends ManagedStackError { - readonly code = "UNSUPPORTED_MANAGED_REGISTRY_VERSION"; +export class UnsupportedManagedRegistryVersionError extends Data.TaggedError( + "UnsupportedManagedRegistryVersionError", +)<{ + readonly found: number; + readonly supported: number; +}> { + readonly code = "UNSUPPORTED_MANAGED_REGISTRY_VERSION" as const; - constructor( - readonly found: number, - readonly supported: number, - ) { - super(`Managed registry version ${found} is unsupported; expected version ${supported}`); - this.name = "UnsupportedManagedRegistryVersionError"; + override get message(): string { + return `Managed registry version ${this.found} is unsupported; expected version ${this.supported}`; } } -export class DuplicateManagedIdentityError extends ManagedStackError { - readonly code = "DUPLICATE_MANAGED_IDENTITY"; - - constructor( - readonly identityId: string, - readonly existingClaim: string, - readonly requestedClaim: string, - ) { - super( - `Managed identity ${identityId} is already claimed by ${existingClaim}; refusing a second claim from ${requestedClaim}`, - ); - this.name = "DuplicateManagedIdentityError"; +export class DuplicateManagedIdentityError extends Data.TaggedError( + "DuplicateManagedIdentityError", +)<{ + readonly identityId: string; + readonly existingClaim: string; + readonly requestedClaim: string; +}> { + readonly code = "DUPLICATE_MANAGED_IDENTITY" as const; + + override get message(): string { + return `Managed identity ${this.identityId} is already claimed by ${this.existingClaim}; refusing a second claim from ${this.requestedClaim}`; } } -export class InvalidManagedStackNameError extends ManagedStackError { - readonly code = "MANAGED_INVALID_STACK_NAME"; +export class InvalidManagedStackNameError extends Data.TaggedError("InvalidManagedStackNameError")<{ + readonly stackName: string; +}> { + readonly code = "MANAGED_INVALID_STACK_NAME" as const; - constructor(readonly stackName: string) { - super(`Invalid managed stack name: ${stackName}`); - this.name = "InvalidManagedStackNameError"; + override get message(): string { + return `Invalid managed stack name: ${this.stackName}`; } } -export class InvalidManagedOwnerPidError extends ManagedStackError { - readonly code = "MANAGED_INVALID_OWNER_PID"; +export class InvalidManagedOwnerPidError extends Data.TaggedError("InvalidManagedOwnerPidError")<{ + readonly ownerPid: number; +}> { + readonly code = "MANAGED_INVALID_OWNER_PID" as const; - constructor(readonly ownerPid: number) { - super(`Invalid managed operation owner pid ${ownerPid}`); - this.name = "InvalidManagedOwnerPidError"; + override get message(): string { + return `Invalid managed operation owner pid ${this.ownerPid}`; } } -export class InvalidManagedPortError extends ManagedStackError { - readonly code = "MANAGED_INVALID_PORT"; +export class InvalidManagedPortError extends Data.TaggedError("InvalidManagedPortError")<{ + readonly port: number; + readonly key: string; +}> { + readonly code = "MANAGED_INVALID_PORT" as const; - constructor( - readonly port: number, - readonly key: string, - ) { - super(`Invalid managed port ${port} for ${key}`); - this.name = "InvalidManagedPortError"; + override get message(): string { + return `Invalid managed port ${this.port} for ${this.key}`; } } -export class ManagedStackNotFoundError extends ManagedStackError { - readonly code = "MANAGED_STACK_NOT_FOUND"; +export class ManagedStackNotFoundError extends Data.TaggedError("ManagedStackNotFoundError")<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_NOT_FOUND" as const; - constructor(readonly stackId: string) { - super(`Managed stack ${stackId} was not found`); - this.name = "ManagedStackNotFoundError"; + override get message(): string { + return `Managed stack ${this.stackId} was not found`; } } -export class ManagedStackNotStoppedError extends ManagedStackError { - readonly code = "MANAGED_STACK_NOT_STOPPED"; +export class ManagedStackNotStoppedError extends Data.TaggedError("ManagedStackNotStoppedError")<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_NOT_STOPPED" as const; - constructor(readonly stackId: string) { - super(`Managed stack ${stackId} must be safely stopped before deletion`); - this.name = "ManagedStackNotStoppedError"; + override get message(): string { + return `Managed stack ${this.stackId} must be safely stopped before deletion`; } } -export class ManagedPendingStackUpdateError extends ManagedStackError { - readonly code = "MANAGED_PENDING_STACK_UPDATE"; +export class ManagedPendingStackUpdateError extends Data.TaggedError( + "ManagedPendingStackUpdateError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_PENDING_STACK_UPDATE" as const; - constructor(readonly stackId: string) { - super( - `Managed stack ${stackId} is still pending publication and cannot be reconfigured through an update`, - ); - this.name = "ManagedPendingStackUpdateError"; + override get message(): string { + return `Managed stack ${this.stackId} is still pending publication and cannot be reconfigured through an update`; } } -export class ManagedOperationInProgressError extends ManagedStackError { - readonly code = "MANAGED_OPERATION_IN_PROGRESS"; +export class ManagedOperationInProgressError extends Data.TaggedError( + "ManagedOperationInProgressError", +)<{ + readonly stackId: string; + readonly operation: ManagedOperationRecord; +}> { + readonly code = "MANAGED_OPERATION_IN_PROGRESS" as const; - constructor( - readonly stackId: string, - readonly operation: ManagedOperationRecord, - ) { - super(`Managed stack ${stackId} already has an active ${operation.kind} operation`); - this.name = "ManagedOperationInProgressError"; + override get message(): string { + return `Managed stack ${this.stackId} already has an active ${this.operation.kind} operation`; } } -export class ManagedOperationOwnershipError extends ManagedStackError { - readonly code = "MANAGED_OPERATION_OWNERSHIP_MISMATCH"; +export class ManagedOperationOwnershipError extends Data.TaggedError( + "ManagedOperationOwnershipError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_OPERATION_OWNERSHIP_MISMATCH" as const; - constructor(readonly stackId: string) { - super(`The active operation for managed stack ${stackId} is owned by another caller`); - this.name = "ManagedOperationOwnershipError"; + override get message(): string { + return `The active operation for managed stack ${this.stackId} is owned by another caller`; } } -export class ManagedPortReservationError extends ManagedStackError { - readonly code = "MANAGED_PORT_ALREADY_RESERVED"; +export class ManagedPortReservationError extends Data.TaggedError("ManagedPortReservationError")<{ + readonly port: number; + readonly ownerStackId: string; +}> { + readonly code = "MANAGED_PORT_ALREADY_RESERVED" as const; - constructor( - readonly port: number, - readonly ownerStackId: string, - ) { - super(`Port ${port} is already reserved by managed stack ${ownerStackId}`); - this.name = "ManagedPortReservationError"; + override get message(): string { + return `Port ${this.port} is already reserved by managed stack ${this.ownerStackId}`; } } -export class ManagedRunningStackPortChangeError extends ManagedStackError { - readonly code = "MANAGED_RUNNING_STACK_PORT_CHANGE"; +export class ManagedRunningStackPortChangeError extends Data.TaggedError( + "ManagedRunningStackPortChangeError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_RUNNING_STACK_PORT_CHANGE" as const; - constructor(readonly stackId: string) { - super(`Managed stack ${stackId} cannot change ports while it continues to occupy them`); - this.name = "ManagedRunningStackPortChangeError"; + override get message(): string { + return `Managed stack ${this.stackId} cannot change ports while it continues to occupy them`; } } -export class UnsafeManagedStackPathError extends ManagedStackError { - readonly code = "UNSAFE_MANAGED_STACK_PATH"; +/** + * The default `reason` prefix, used by the stack-removal guard that motivated + * this failure. State-root refusals pass their own `reason`. + */ +const UNSAFE_MANAGED_STACK_PATH_REASON = "Refusing to remove an unsafe managed stack path"; + +export class UnsafeManagedStackPathError extends Data.TaggedError("UnsafeManagedStackPathError")<{ + readonly path: string; + /** + * Names which refusal this is, since the same coded failure guards both + * stack removal and state roots. Defaults to the stack-removal wording. + */ + readonly reason?: string; +}> { + readonly code = "UNSAFE_MANAGED_STACK_PATH" as const; /** * The refused path is quoted rather than interpolated bare: the values worth * refusing include blank and whitespace-only ones, which would otherwise - * render as an empty message tail. `reason` names which refusal this is, - * since the same coded failure guards both stack removal and state roots. + * render as an empty message tail. */ - constructor( - readonly path: string, - reason = "Refusing to remove an unsafe managed stack path", - ) { - super(`${reason}: ${JSON.stringify(path)}`); - this.name = "UnsafeManagedStackPathError"; + override get message(): string { + return `${this.reason ?? UNSAFE_MANAGED_STACK_PATH_REASON}: ${JSON.stringify(this.path)}`; } } -export class ManagedStackInitializationError extends ManagedStackError { - readonly code = "MANAGED_STACK_INITIALIZATION_FAILED"; +export class ManagedStackInitializationError extends Data.TaggedError( + "ManagedStackInitializationError", +)<{ + readonly stackId: string; + readonly cause: unknown; + readonly cleanupErrors: ReadonlyArray; +}> { + readonly code = "MANAGED_STACK_INITIALIZATION_FAILED" as const; - constructor( - readonly stackId: string, - override readonly cause: unknown, - readonly cleanupErrors: ReadonlyArray = [], - ) { - super(`Managed stack ${stackId} could not be initialized`); - this.name = "ManagedStackInitializationError"; + override get message(): string { + return `Managed stack ${this.stackId} could not be initialized`; } } -export class ManagedStackPublicationTimeoutError extends ManagedStackError { - readonly code = "MANAGED_STACK_PUBLICATION_TIMEOUT"; +export class ManagedStackPublicationTimeoutError extends Data.TaggedError( + "ManagedStackPublicationTimeoutError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_PUBLICATION_TIMEOUT" as const; - constructor(readonly stackId: string) { - super(`Timed out waiting for managed stack ${stackId} to be published`); - this.name = "ManagedStackPublicationTimeoutError"; + override get message(): string { + return `Timed out waiting for managed stack ${this.stackId} to be published`; } } -export class ManagedAbandonedOperationError extends ManagedStackError { - readonly code = "MANAGED_OPERATION_REQUIRES_RECONCILIATION"; +export class ManagedAbandonedOperationError extends Data.TaggedError( + "ManagedAbandonedOperationError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_OPERATION_REQUIRES_RECONCILIATION" as const; - constructor(readonly stackId: string) { - super(`Managed stack ${stackId} has an abandoned operation that must be reconciled`); - this.name = "ManagedAbandonedOperationError"; + override get message(): string { + return `Managed stack ${this.stackId} has an abandoned operation that must be reconciled`; } } /** - * Every `code` literal declared by a {@link ManagedStackError} subclass. + * Any managed registry failure. + * + * Every managed failure is a `Data.TaggedError`, so they cannot share a base + * class — each one extends its own generated base. The hierarchy is therefore a + * union type rather than a root class, and {@link isManagedStackError} is the + * runtime equivalent of the old `instanceof` check. + */ +export type ManagedStackError = + | DuplicateManagedIdentityError + | InvalidManagedIdentityError + | InvalidManagedOwnerPidError + | InvalidManagedPortError + | InvalidManagedStackNameError + | ManagedAbandonedOperationError + | ManagedOperationInProgressError + | ManagedOperationOwnershipError + | ManagedPendingStackUpdateError + | ManagedPortReservationError + | ManagedRunningStackPortChangeError + | ManagedStackInitializationError + | ManagedStackNotFoundError + | ManagedStackNotStoppedError + | ManagedStackPublicationTimeoutError + | UnsafeManagedStackPathError + | UnsupportedManagedRegistryVersionError; + +/** + * Every `code` literal declared by a managed failure. * - * Managed failures are plain `Error` subclasses: none carries a `_tag`, and - * identifier minification renames the constructors, so `code` is the only - * discriminator consumers can dispatch on. This list is the machine-readable - * form of that contract. `managed-model.unit.test.ts` keeps it exhaustive - * against the exported classes, and the CLI's telemetry classifier types its - * dispatch table as `Record` so a new code cannot be - * added here without classifying it there. + * `code` is the wire-level contract: identifier minification renames the + * constructors, so a release build's telemetry and any cross-runtime consumer + * need a value the bundler cannot touch. `managed-model.unit.test.ts` keeps + * this list exhaustive against the exported classes, and the CLI's telemetry + * classifier types its dispatch table as `Record` so a + * new code cannot be added here without classifying it there. * * This module must stay free of runtime-specific imports: it is published as * `@supabase/stack/managed-model` precisely so consumers can import the codes @@ -319,3 +367,47 @@ export const MANAGED_ERROR_CODES = [ ] as const; export type ManagedErrorCode = (typeof MANAGED_ERROR_CODES)[number]; + +/** + * The single source of truth linking each managed `code` to the `_tag` of the + * class that declares it. + * + * `_tag` is the Effect-native discriminant (`Effect.catchTag`, structural + * dispatch) and `code` is the stable wire-level contract. Consumers that key a + * table by one and dispatch on the other — the CLI's telemetry classifier is + * the motivating case — derive it from this map instead of restating all + * seventeen pairs by hand. + */ +export const MANAGED_ERROR_TAG_BY_CODE = { + DUPLICATE_MANAGED_IDENTITY: "DuplicateManagedIdentityError", + INVALID_MANAGED_IDENTITY: "InvalidManagedIdentityError", + MANAGED_INVALID_OWNER_PID: "InvalidManagedOwnerPidError", + MANAGED_INVALID_PORT: "InvalidManagedPortError", + MANAGED_INVALID_STACK_NAME: "InvalidManagedStackNameError", + MANAGED_OPERATION_IN_PROGRESS: "ManagedOperationInProgressError", + MANAGED_OPERATION_OWNERSHIP_MISMATCH: "ManagedOperationOwnershipError", + MANAGED_OPERATION_REQUIRES_RECONCILIATION: "ManagedAbandonedOperationError", + MANAGED_PENDING_STACK_UPDATE: "ManagedPendingStackUpdateError", + MANAGED_PORT_ALREADY_RESERVED: "ManagedPortReservationError", + MANAGED_RUNNING_STACK_PORT_CHANGE: "ManagedRunningStackPortChangeError", + MANAGED_STACK_INITIALIZATION_FAILED: "ManagedStackInitializationError", + MANAGED_STACK_NOT_FOUND: "ManagedStackNotFoundError", + MANAGED_STACK_NOT_STOPPED: "ManagedStackNotStoppedError", + MANAGED_STACK_PUBLICATION_TIMEOUT: "ManagedStackPublicationTimeoutError", + UNSAFE_MANAGED_STACK_PATH: "UnsafeManagedStackPathError", + UNSUPPORTED_MANAGED_REGISTRY_VERSION: "UnsupportedManagedRegistryVersionError", +} as const satisfies Record; + +const MANAGED_ERROR_TAGS: ReadonlySet = new Set(Object.values(MANAGED_ERROR_TAG_BY_CODE)); + +/** + * Whether a value is a managed registry failure. Replaces the `instanceof` + * check against the removed `ManagedStackError` root class: the union's members + * each extend their own `Data.TaggedError` base, so the shared discriminator is + * the tag rather than a prototype chain. + */ +export function isManagedStackError(error: unknown): error is ManagedStackError { + if (!(error instanceof Error) || !("_tag" in error)) return false; + const tag: unknown = error._tag; + return typeof tag === "string" && MANAGED_ERROR_TAGS.has(tag); +} diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index 6872d0d4f5..fa0a9b1198 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -18,7 +18,10 @@ const nonEmpty = (value: string | undefined): string | undefined => { const requireManagedStateRoot = (stateRoot: string): string => { const trimmed = nonEmpty(stateRoot); if (trimmed === undefined) { - throw new UnsafeManagedStackPathError(stateRoot, "Refusing a blank managed state root"); + throw new UnsafeManagedStackPathError({ + path: stateRoot, + reason: "Refusing a blank managed state root", + }); } return resolve(trimmed); }; @@ -93,7 +96,7 @@ export const assertManagedStackRoot = ( const expected = resolve(managedStackPaths(stateRoot, stackId).root); const actual = resolve(stackRoot); if (actual !== expected) { - throw new UnsafeManagedStackPathError(stackRoot); + throw new UnsafeManagedStackPathError({ path: stackRoot }); } return actual; }; diff --git a/packages/stack/src/managed/repository-memory.ts b/packages/stack/src/managed/repository-memory.ts index 2683c70cde..59c85f28d6 100644 --- a/packages/stack/src/managed/repository-memory.ts +++ b/packages/stack/src/managed/repository-memory.ts @@ -120,7 +120,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = const requireStack = (stackId: string): ManagedStackRecord => { const stack = stacks.get(stackId); if (stack === undefined) { - throw new ManagedStackNotFoundError(stackId); + throw new ManagedStackNotFoundError({ stackId }); } return stack; }; @@ -137,7 +137,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = operation.stackId !== stackId || operation.status !== "active" ) { - throw new ManagedOperationOwnershipError(stackId); + throw new ManagedOperationOwnershipError({ stackId }); } return operation; }; @@ -151,7 +151,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = for (const assignment of next.ports) { const owner = portOwners.get(assignment.port); if (owner !== undefined && owner !== next.id) { - throw new ManagedPortReservationError(assignment.port, owner); + throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: owner }); } } } @@ -166,7 +166,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = for (const assignment of next.ports) { const owner = portOwners.get(assignment.port); if (owner !== undefined && owner !== next.id) { - throw new ManagedPortReservationError(assignment.port, owner); + throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: owner }); } portOwners.set(assignment.port, next.id); } @@ -217,11 +217,11 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = projects.add(input.identity.projectId); const checkout = checkouts.get(input.identity.checkoutId); if (checkout !== undefined && checkout.projectId !== input.identity.projectId) { - throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - checkout.projectId, - input.identity.projectId, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: checkout.projectId, + requestedClaim: input.identity.projectId, + }); } checkouts.set(input.identity.checkoutId, { id: input.identity.checkoutId, @@ -230,11 +230,11 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = const context = contexts.get(input.identity.contextId); if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { - throw new DuplicateManagedIdentityError( - input.identity.contextId, - context.checkoutId, - input.identity.checkoutId, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.identity.contextId, + existingClaim: context.checkoutId, + requestedClaim: input.identity.checkoutId, + }); } contexts.set(input.identity.contextId, { id: input.identity.contextId, @@ -248,21 +248,21 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = existingLocation !== undefined && existingLocation.canonicalPath !== input.canonicalPath ) { - throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - existingLocation.canonicalPath, - input.canonicalPath, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: existingLocation.canonicalPath, + requestedClaim: input.canonicalPath, + }); } const pathOwner = [...locations.values()].find( (location) => location.canonicalPath === input.canonicalPath, ); if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { - throw new DuplicateManagedIdentityError( - input.canonicalPath, - pathOwner.checkoutId, - input.identity.checkoutId, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.canonicalPath, + existingClaim: pathOwner.checkoutId, + requestedClaim: input.identity.checkoutId, + }); } locations.set(existingLocation?.id ?? input.locationId, { id: existingLocation?.id ?? input.locationId, @@ -317,7 +317,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = now: input.now, }); if (!claimed.acquired) { - throw new ManagedOperationOwnershipError(stack.id); + throw new ManagedOperationOwnershipError({ stackId: stack.id }); } return { outcome: "create", stack: copy(stack), operation: claimed.operation }; }); @@ -346,7 +346,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = requireOwnedOperation(stackId, operationToken); const stack = requireStack(stackId); if (stack.status !== "pending") { - throw new ManagedOperationOwnershipError(stackId); + throw new ManagedOperationOwnershipError({ stackId }); } discardPendingStack(stack, operationToken); }, diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index 0f6ffd59b3..1284d54eb9 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -121,7 +121,7 @@ export const isUsableManagedOwnerPid = (ownerPid: number | undefined): ownerPid */ export const assertManagedOwnerPid = (ownerPid: number | undefined): void => { if (ownerPid !== undefined && !isUsableManagedOwnerPid(ownerPid)) { - throw new InvalidManagedOwnerPidError(ownerPid); + throw new InvalidManagedOwnerPidError({ ownerPid }); } }; @@ -157,13 +157,13 @@ export const validateManagedPortAssignments = ( const numbers = new Set(); for (const assignment of ports) { if (!Number.isInteger(assignment.port) || assignment.port < 1 || assignment.port > 65_535) { - throw new InvalidManagedPortError(assignment.port, assignment.key); + throw new InvalidManagedPortError({ port: assignment.port, key: assignment.key }); } if (keys.has(assignment.key)) { throw new Error(`Duplicate managed port key ${assignment.key}`); } if (numbers.has(assignment.port)) { - throw new ManagedPortReservationError(assignment.port, stackId); + throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: stackId }); } keys.add(assignment.key); numbers.add(assignment.port); @@ -196,7 +196,7 @@ export const reconcileManagedPortAssignments = ( managedStackOccupiesPorts(targetLifecycle) && !portNumbersEqual(stack.ports, reconciled) ) { - throw new ManagedRunningStackPortChangeError(stack.id); + throw new ManagedRunningStackPortChangeError({ stackId: stack.id }); } return reconciled; }; @@ -213,9 +213,9 @@ export const reconcileManagedPortAssignments = ( */ export const assertManagedStackUpdatable = (stack: ManagedStackRecord): void => { if (stack.status === "tombstoned") { - throw new ManagedStackNotFoundError(stack.id); + throw new ManagedStackNotFoundError({ stackId: stack.id }); } if (stack.status === "pending") { - throw new ManagedPendingStackUpdateError(stack.id); + throw new ManagedPendingStackUpdateError({ stackId: stack.id }); } }; diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index a4b226c300..6ff4511583 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -204,10 +204,10 @@ export const makeManagedStackService = ( // `undefined`, which would make `resolveManagedStateRoot` silently fall back // to `SUPABASE_HOME`/the user's home directory instead of failing loudly. if (options.stateRoot === undefined) { - throw new UnsafeManagedStackPathError( - String(options.stateRoot), - "Refusing to start a managed stack service without an explicit state root", - ); + throw new UnsafeManagedStackPathError({ + path: String(options.stateRoot), + reason: "Refusing to start a managed stack service without an explicit state root", + }); } const stateRoot = resolveManagedStateRoot({ stateRoot: options.stateRoot }); const idFactory = options.idFactory ?? randomUUID; @@ -301,7 +301,7 @@ export const makeManagedStackService = ( now: now(), }); if (!claimed.acquired) { - throw new ManagedOperationInProgressError(stackId, claimed.operation); + throw new ManagedOperationInProgressError({ stackId, operation: claimed.operation }); } return claimed.operation; }; @@ -319,20 +319,20 @@ export const makeManagedStackService = ( while (performance.now() <= deadline) { const current = options.repository.getStack(pending.id); if (current === undefined) { - throw new ManagedAbandonedOperationError(pending.id); + throw new ManagedAbandonedOperationError({ stackId: pending.id }); } if (current.status === "active") { return current; } if (current.status === "tombstoned") { - throw new ManagedStackNotFoundError(current.id); + throw new ManagedStackNotFoundError({ stackId: current.id }); } // Never sleep past the deadline: the timeout is the caller's bound, not a // floor a long poll interval may overshoot by a whole interval. await wait(Math.max(Math.min(pollMs, deadline - performance.now()), 0)); pollMs = backOffPublicationPoll(pollMs); } - throw new ManagedStackPublicationTimeoutError(pending.id); + throw new ManagedStackPublicationTimeoutError({ stackId: pending.id }); }; const updateStackRecord = async ( @@ -374,7 +374,7 @@ export const makeManagedStackService = ( async provisionOrdinaryStack(provisionOptions) { const stackName = provisionOptions.stackName ?? DEFAULT_MANAGED_STACK_NAME; if (!stackNamePattern.test(stackName)) { - throw new InvalidManagedStackNameError(stackName); + throw new InvalidManagedStackNameError({ stackName }); } const canonicalPath = await canonicalizeOrdinaryWorkspacePath(provisionOptions.workspacePath); const marker = await ensureOrdinaryWorkspaceIdentity(canonicalPath, idFactory); @@ -395,7 +395,10 @@ export const makeManagedStackService = ( if (prepared.outcome === "existing") { if (prepared.stack.status === "active") { if (prepared.operation !== undefined) { - throw new ManagedOperationInProgressError(prepared.stack.id, prepared.operation); + throw new ManagedOperationInProgressError({ + stackId: prepared.stack.id, + operation: prepared.operation, + }); } const stack = await applyRequestedConfiguration( prepared.stack, @@ -409,7 +412,7 @@ export const makeManagedStackService = ( }; } if (prepared.operation === undefined) { - throw new ManagedAbandonedOperationError(prepared.stack.id); + throw new ManagedAbandonedOperationError({ stackId: prepared.stack.id }); } // A stored pid that is not a usable pid means there is no owner to wait // for, exactly as a missing one does: probing it could report a dead @@ -419,7 +422,7 @@ export const makeManagedStackService = ( !isUsableManagedOwnerPid(prepared.operation.ownerPid) || !(await isProcessAlive(prepared.operation.ownerPid)) ) { - throw new ManagedAbandonedOperationError(prepared.stack.id); + throw new ManagedAbandonedOperationError({ stackId: prepared.stack.id }); } const published = await applyRequestedConfiguration( await awaitPublication(prepared.stack), @@ -466,7 +469,11 @@ export const makeManagedStackService = ( cleanupErrors.push(error); } } - throw new ManagedStackInitializationError(prepared.stack.id, cause, cleanupErrors); + throw new ManagedStackInitializationError({ + stackId: prepared.stack.id, + cause, + cleanupErrors, + }); } }, async inspectOrdinaryWorkspace(workspacePath) { @@ -497,7 +504,7 @@ export const makeManagedStackService = ( async deleteStack(stackId, deleteOptions) { const existing = options.repository.getStack(stackId); if (existing === undefined) { - throw new ManagedStackNotFoundError(stackId); + throw new ManagedStackNotFoundError({ stackId }); } if (existing.status === "tombstoned") { return { @@ -510,7 +517,7 @@ export const makeManagedStackService = ( try { const current = options.repository.getStack(stackId); if (current === undefined) { - throw new ManagedStackNotFoundError(stackId); + throw new ManagedStackNotFoundError({ stackId }); } if (current.status === "tombstoned") { const dataReclamation = await reclaimStackState(current); @@ -519,7 +526,7 @@ export const makeManagedStackService = ( } if (current.lifecycle !== "stopped") { if (deleteOptions?.stop === undefined) { - throw new ManagedStackNotStoppedError(stackId); + throw new ManagedStackNotStoppedError({ stackId }); } await deleteOptions.stop(current); options.repository.updateStack({ diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index d0a54cc67f..fbc324f44d 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -208,7 +208,10 @@ const initializeSchema = (database: ManagedSqliteDatabase): void => { const versionRow = database.prepare("PRAGMA user_version").get(); const version = getNumber(versionRow, "user_version"); if (version !== 0 && version !== MANAGED_REGISTRY_SCHEMA_VERSION) { - throw new UnsupportedManagedRegistryVersionError(version, MANAGED_REGISTRY_SCHEMA_VERSION); + throw new UnsupportedManagedRegistryVersionError({ + found: version, + supported: MANAGED_REGISTRY_SCHEMA_VERSION, + }); } if (version === MANAGED_REGISTRY_SCHEMA_VERSION) { database.exec("COMMIT"); @@ -428,7 +431,7 @@ const getStack = ( const requireStack = (database: ManagedSqliteDatabase, stackId: string): ManagedStackRecord => { const stack = getStack(database, stackId); if (stack === undefined) { - throw new ManagedStackNotFoundError(stackId); + throw new ManagedStackNotFoundError({ stackId }); } return stack; }; @@ -450,7 +453,7 @@ const requireOwnedOperation = ( ): ManagedOperationRecord => { const operation = getActiveOperation(database, stackId); if (operation === undefined || operation.token !== operationToken) { - throw new ManagedOperationOwnershipError(stackId); + throw new ManagedOperationOwnershipError({ stackId }); } return operation; }; @@ -475,7 +478,10 @@ const replacePorts = ( ) .get([assignment.port, stackId]); if (owner !== undefined) { - throw new ManagedPortReservationError(assignment.port, getString(owner, "stack_id")); + throw new ManagedPortReservationError({ + port: assignment.port, + ownerStackId: getString(owner, "stack_id"), + }); } } } @@ -508,7 +514,7 @@ const claimOperation = ( .run([input.token, input.stackId, input.kind, input.ownerPid ?? null, input.now]); const operation = getActiveOperation(database, input.stackId); if (operation === undefined) { - throw new ManagedOperationOwnershipError(input.stackId); + throw new ManagedOperationOwnershipError({ stackId: input.stackId }); } return { acquired: true, operation }; }); @@ -579,11 +585,11 @@ export const createSqliteManagedStackRepository = ( checkoutRow !== undefined && getString(checkoutRow, "project_id") !== input.identity.projectId ) { - throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - getString(checkoutRow, "project_id"), - input.identity.projectId, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: getString(checkoutRow, "project_id"), + requestedClaim: input.identity.projectId, + }); } database .prepare("INSERT OR IGNORE INTO checkouts (id, project_id, created_at) VALUES (?, ?, ?)") @@ -596,11 +602,11 @@ export const createSqliteManagedStackRepository = ( contextRow !== undefined && getString(contextRow, "checkout_id") !== input.identity.checkoutId ) { - throw new DuplicateManagedIdentityError( - input.identity.contextId, - getString(contextRow, "checkout_id"), - input.identity.checkoutId, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.identity.contextId, + existingClaim: getString(contextRow, "checkout_id"), + requestedClaim: input.identity.checkoutId, + }); } database .prepare(`INSERT OR IGNORE INTO contexts (id, checkout_id, created_at) VALUES (?, ?, ?)`) @@ -613,11 +619,11 @@ export const createSqliteManagedStackRepository = ( checkoutLocation !== undefined && getString(checkoutLocation, "canonical_path") !== input.canonicalPath ) { - throw new DuplicateManagedIdentityError( - input.identity.checkoutId, - getString(checkoutLocation, "canonical_path"), - input.canonicalPath, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: getString(checkoutLocation, "canonical_path"), + requestedClaim: input.canonicalPath, + }); } const pathLocation = database .prepare("SELECT * FROM checkout_locations WHERE canonical_path = ?") @@ -626,11 +632,11 @@ export const createSqliteManagedStackRepository = ( pathLocation !== undefined && getString(pathLocation, "checkout_id") !== input.identity.checkoutId ) { - throw new DuplicateManagedIdentityError( - input.canonicalPath, - getString(pathLocation, "checkout_id"), - input.identity.checkoutId, - ); + throw new DuplicateManagedIdentityError({ + identityId: input.canonicalPath, + existingClaim: getString(pathLocation, "checkout_id"), + requestedClaim: input.identity.checkoutId, + }); } if (checkoutLocation === undefined) { database @@ -669,7 +675,7 @@ export const createSqliteManagedStackRepository = ( const stack = requireStack(database, input.stackId); const operation = getActiveOperation(database, input.stackId); if (operation === undefined) { - throw new ManagedOperationOwnershipError(input.stackId); + throw new ManagedOperationOwnershipError({ stackId: input.stackId }); } return { outcome: "create", stack, operation }; }); @@ -695,7 +701,7 @@ export const createSqliteManagedStackRepository = ( requireOwnedOperation(database, stackId, operationToken); const stack = requireStack(database, stackId); if (stack.status !== "pending") { - throw new ManagedOperationOwnershipError(stackId); + throw new ManagedOperationOwnershipError({ stackId }); } database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); }); From cc5d1efcc0d0b2bb13cdd1e09aea4f018916cd81 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 13:18:39 +0200 Subject: [PATCH 14/18] refactor(stack): make the managed core Effect-native - model the repository and service as Context.Service layers with typed errors - scope SQLite handles, run transactions as effects, and schedule publication polling - keep the Promise facade as a thin ManagedRuntime edge with an unchanged API Co-Authored-By: Claude Fable 5 --- packages/stack/src/entrypoints.unit.test.ts | 8 +- packages/stack/src/managed-bun.ts | 19 +- packages/stack/src/managed-node.ts | 23 +- .../src/managed-service.integration.test.ts | 792 +++++----- packages/stack/src/managed.ts | 15 +- packages/stack/src/managed/create-service.ts | 229 ++- packages/stack/src/managed/failure.ts | 43 + packages/stack/src/managed/identity.ts | 62 +- packages/stack/src/managed/paths.ts | 20 + .../stack/src/managed/repository-memory.ts | 589 +++++--- packages/stack/src/managed/repository.ts | 114 +- packages/stack/src/managed/service.ts | 1280 ++++++++++------- packages/stack/src/managed/sqlite-bun.ts | 39 +- packages/stack/src/managed/sqlite-node.ts | 39 +- packages/stack/src/managed/sqlite.ts | 885 +++++++----- 15 files changed, 2607 insertions(+), 1550 deletions(-) create mode 100644 packages/stack/src/managed/failure.ts diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index d41675a9f8..69ed5de94b 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -64,7 +64,8 @@ describe("@supabase/stack entrypoints", () => { it("exposes managed policy through its own entrypoint", () => { expect(managed).toHaveProperty("createManagedStackService"); expect(managed).toHaveProperty("makeManagedStackService"); - expect(managed).toHaveProperty("openBunSqliteManagedStackRepository"); + expect(managed).toHaveProperty("ManagedStackService"); + expect(managed).toHaveProperty("bunSqliteManagedStackRepositoryLayer"); expect(nodeRoot).not.toHaveProperty("createManagedStackService"); }); @@ -91,11 +92,14 @@ describe("@supabase/stack entrypoints", () => { "ManagedStackNotFoundError", "ManagedStackNotStoppedError", "ManagedStackPublicationTimeoutError", + "ManagedStackRepository", + "ManagedStackService", "ORDINARY_WORKSPACE_IDENTITY_VERSION", "UnsafeManagedStackPathError", "UnsupportedManagedRegistryVersionError", "assertManagedStackRoot", "assertManagedUuid", + "bunSqliteManagedStackRepositoryLayer", "canonicalizeOrdinaryWorkspacePath", "createManagedStackService", "createManagedUuid", @@ -104,9 +108,9 @@ describe("@supabase/stack entrypoints", () => { "makeManagedStackService", "managedRegistryPath", "managedStackPaths", - "openBunSqliteManagedStackRepository", "ordinaryWorkspaceIdentityPath", "readOrdinaryWorkspaceIdentity", + "requireExplicitManagedStateRoot", "resolveManagedStateRoot", ]); }); diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts index 7136605679..755f77dca9 100644 --- a/packages/stack/src/managed-bun.ts +++ b/packages/stack/src/managed-bun.ts @@ -1,12 +1,21 @@ +import { BunFileSystem } from "@effect/platform-bun"; import { createManagedStackServiceWith, + makeManagedStackServiceWith, type CreateManagedStackServiceOptions, + type MakeManagedStackServiceOptions, + type ManagedStackServiceHandle, } from "./managed/create-service.ts"; -import { openBunSqliteManagedStackRepository } from "./managed/sqlite-bun.ts"; +import { bunSqliteManagedStackRepositoryLayer } from "./managed/sqlite-bun.ts"; export * from "./managed.ts"; -export { openBunSqliteManagedStackRepository }; -export type { CreateManagedStackServiceOptions }; +export { bunSqliteManagedStackRepositoryLayer }; -export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => - createManagedStackServiceWith(openBunSqliteManagedStackRepository, options); +export const createManagedStackService = ( + options: CreateManagedStackServiceOptions = {}, +): ManagedStackServiceHandle => + createManagedStackServiceWith(BunFileSystem.layer, bunSqliteManagedStackRepositoryLayer, options); + +export const makeManagedStackService = ( + options: MakeManagedStackServiceOptions, +): ManagedStackServiceHandle => makeManagedStackServiceWith(BunFileSystem.layer, options); diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts index 3792a3509d..3aaa37a579 100644 --- a/packages/stack/src/managed-node.ts +++ b/packages/stack/src/managed-node.ts @@ -1,12 +1,25 @@ +import { NodeFileSystem } from "@effect/platform-node"; import { createManagedStackServiceWith, + makeManagedStackServiceWith, type CreateManagedStackServiceOptions, + type MakeManagedStackServiceOptions, + type ManagedStackServiceHandle, } from "./managed/create-service.ts"; -import { openNodeSqliteManagedStackRepository } from "./managed/sqlite-node.ts"; +import { nodeSqliteManagedStackRepositoryLayer } from "./managed/sqlite-node.ts"; export * from "./managed.ts"; -export { openNodeSqliteManagedStackRepository }; -export type { CreateManagedStackServiceOptions }; +export { nodeSqliteManagedStackRepositoryLayer }; -export const createManagedStackService = (options: CreateManagedStackServiceOptions = {}) => - createManagedStackServiceWith(openNodeSqliteManagedStackRepository, options); +export const createManagedStackService = ( + options: CreateManagedStackServiceOptions = {}, +): ManagedStackServiceHandle => + createManagedStackServiceWith( + NodeFileSystem.layer, + nodeSqliteManagedStackRepositoryLayer, + options, + ); + +export const makeManagedStackService = ( + options: MakeManagedStackServiceOptions, +): ManagedStackServiceHandle => makeManagedStackServiceWith(NodeFileSystem.layer, options); diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 973c76cc2f..0b2672bf48 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { Context, Effect, ManagedRuntime } from "effect"; import { managedStackContractFixtures } from "./managed-stack-contract.ts"; import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; import { @@ -41,16 +42,37 @@ import { UnsafeManagedStackPathError, UnsupportedManagedRegistryVersionError, type ManagedStackConfiguration, + type ManagedStackRecord, } from "./managed/model.ts"; import { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; -import type { ManagedStackRepository } from "./managed/repository.ts"; +import { ManagedStackRepository, type ManagedStackRepositoryShape } from "./managed/repository.ts"; +import type { MakeManagedStackServiceOptions, ManagedStackServiceHandle } from "./managed-bun.ts"; import { + bunSqliteManagedStackRepositoryLayer, + createManagedStackService, makeManagedStackService, - type ManagedStackService, - type ManagedStackServiceOptions, -} from "./managed/service.ts"; -import { openBunSqliteManagedStackRepository } from "./managed/sqlite-bun.ts"; -import { createManagedStackService } from "./managed-bun.ts"; +} from "./managed-bun.ts"; + +/** + * Both registry adapters decide synchronously, so a test can run a contract call + * the same way the Promise facade's synchronous accessors do. + */ +const runRepo = Effect.runSync; + +/** + * Opens a registry the way production does, as a scoped layer, for the tests that + * exercise the SQLite adapter itself rather than a managed stack service. The + * layer's scope owns the database handle, so it stays open until `close`. + */ +const openRegistry = ( + databasePath: string, +): { readonly repository: ManagedStackRepositoryShape; readonly close: () => Promise } => { + const runtime = ManagedRuntime.make(bunSqliteManagedStackRepositoryLayer(databasePath)); + return { + repository: Context.get(Effect.runSync(runtime.contextEffect), ManagedStackRepository), + close: () => runtime.dispose(), + }; +}; const temporaryRoots: Array = []; @@ -88,9 +110,12 @@ const findNodeBinary = (): string => { throw new Error("Node is required for the managed SQLite adapter test"); }; -type ServiceOverrides = Omit; +type ServiceOverrides = Omit; -const makeInMemoryService = (root: string, overrides: ServiceOverrides = {}): ManagedStackService => +const makeInMemoryService = ( + root: string, + overrides: ServiceOverrides = {}, +): ManagedStackServiceHandle => makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), @@ -101,16 +126,12 @@ const makeInMemoryService = (root: string, overrides: ServiceOverrides = {}): Ma const makePersistentService = ( root: string, overrides: ServiceOverrides = {}, -): ManagedStackService => { - const stateRoot = join(root, "managed"); - const databasePath = managedRegistryPath(stateRoot); - return makeManagedStackService({ - repository: openBunSqliteManagedStackRepository(databasePath), - stateRoot, +): ManagedStackServiceHandle => + createManagedStackService({ + stateRoot: join(root, "managed"), publicationPollMs: 1, ...overrides, }); -}; /** * Valid managed UUIDs whose lexicographic order is the reverse of the order @@ -155,25 +176,27 @@ const invalidStackNameCases = managedStackContractFixtures .flatMap((scenario) => stackNames(scenario.id).map((name) => [scenario.id, name] as const)); const prepareAbandonedStack = async ( - service: ManagedStackService, + service: ManagedStackServiceHandle, workspace: string, ownerPid?: number, configuration: ManagedStackConfiguration = {}, ) => { - const identity = (await ensureOrdinaryWorkspaceIdentity(workspace)).identity; + const identity = (await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace))).identity; const stackId = crypto.randomUUID(); - const prepared = service.repository.prepareOrdinaryStack({ - identity, - canonicalPath: realpathSync(workspace), - locationId: crypto.randomUUID(), - stackId, - stackName: "default", - paths: managedStackPaths(service.stateRoot, stackId), - operationToken: crypto.randomUUID(), - ownerPid, - now: "2026-08-11T00:00:00.000Z", - configuration, - }); + const prepared = runRepo( + service.repository.prepareOrdinaryStack({ + identity, + canonicalPath: realpathSync(workspace), + locationId: crypto.randomUUID(), + stackId, + stackName: "default", + paths: managedStackPaths(service.stateRoot, stackId), + operationToken: crypto.randomUUID(), + ownerPid, + now: "2026-08-11T00:00:00.000Z", + configuration, + }), + ); if (prepared.outcome !== "create") { throw new Error("Expected an abandoned pending stack"); } @@ -189,7 +212,7 @@ describe("ordinary-folder managed stack contract", () => { const { stack } = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); - service.close(); + await service.close(); const modeOf = (path: string): number => statSync(path).mode & 0o777; expect(modeOf(stateRoot)).toBe(0o700); @@ -207,7 +230,7 @@ describe("ordinary-folder managed stack contract", () => { writeFileSync(registryPath, "", { mode: 0o644 }); const service = makePersistentService(root); - service.close(); + await service.close(); const modeOf = (path: string): number => statSync(path).mode & 0o777; expect(modeOf(stateRoot)).toBe(0o700); @@ -223,15 +246,15 @@ describe("ordinary-folder managed stack contract", () => { expect(result).toEqual({ registered: false, stacks: [] }); expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); - expect(service.repository.listStacks()).toEqual([]); - expect(service.repository.listCheckoutLocations()).toEqual([]); + expect(runRepo(service.repository.listStacks())).toEqual([]); + expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); }); it("reports an existing identity without stacks as not yet registered", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); const service = makeInMemoryService(root); - const marker = await ensureOrdinaryWorkspaceIdentity(workspace); + const marker = await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace)); const result = await service.inspectOrdinaryWorkspace(workspace); @@ -241,13 +264,13 @@ describe("ordinary-folder managed stack contract", () => { it("filters inspected stacks by the complete project, checkout, and context identity", async () => { const root = makeRoot(); const repository = createInMemoryManagedStackRepository(); - let foreignContextStack: ReturnType; - const filteringRepository: ManagedStackRepository = { + let foreignContextStack: ManagedStackRecord | undefined; + const filteringRepository: ManagedStackRepositoryShape = { ...repository, - listStacks(options) { - const stacks = repository.listStacks(options); - return foreignContextStack === undefined ? stacks : [...stacks, foreignContextStack]; - }, + listStacks: (options) => + Effect.map(repository.listStacks(options), (stacks) => + foreignContextStack === undefined ? stacks : [...stacks, foreignContextStack], + ), }; const service = makeManagedStackService({ repository: filteringRepository, @@ -287,8 +310,8 @@ describe("ordinary-folder managed stack contract", () => { await expect( service.provisionOrdinaryStack({ workspacePath: workspace }), ).rejects.toBeInstanceOf(InvalidManagedIdentityError); - expect(service.repository.listStacks()).toEqual([]); - expect(service.repository.listCheckoutLocations()).toEqual([]); + expect(runRepo(service.repository.listStacks())).toEqual([]); + expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); }); it.each(invalidStackNameCases)("rejects %s", async (_fixtureId, stackName) => { @@ -369,7 +392,7 @@ describe("ordinary-folder managed stack contract", () => { contextId: created.selection.contextId, }); - service.close(); + await service.close(); const reopened = makePersistentService(root); const reused = await reopened.provisionOrdinaryStack({ workspacePath: workspace }); @@ -378,7 +401,7 @@ describe("ordinary-folder managed stack contract", () => { expect(reused.selection).toEqual(created.selection); expect(reused.stack.ports).toEqual(created.stack.ports); expect(reopened.listStacks()).toHaveLength(1); - reopened.close(); + await reopened.close(); const registry = new Database(managedRegistryPath(join(root, "managed"))); const columns = registry.query("PRAGMA table_info(stacks)").all(); @@ -425,7 +448,7 @@ describe("ordinary-folder managed stack contract", () => { await initializationGate; }, }); - while (service.repository.listStacks().length === 0) { + while (runRepo(service.repository.listStacks()).length === 0) { await new Promise((resolve) => setTimeout(resolve, 1)); } const second = service.provisionOrdinaryStack({ workspacePath: workspace }); @@ -436,8 +459,8 @@ describe("ordinary-folder managed stack contract", () => { expect(results.map((result) => result.outcome).sort()).toEqual(["create", "reuse"]); expect(new Set(results.map((result) => result.stack.id))).toHaveProperty("size", 1); expect(initializerCalls).toBe(1); - expect(service.repository.listStacks()).toHaveLength(1); - service.close(); + expect(runRepo(service.repository.listStacks())).toHaveLength(1); + await service.close(); }); it("applies the requested configuration after awaiting another caller's publication", async () => { @@ -456,7 +479,7 @@ describe("ordinary-folder managed stack contract", () => { await initializationGate; }, }); - while (service.repository.listStacks().length === 0) { + while (runRepo(service.repository.listStacks()).length === 0) { await new Promise((resolve) => setTimeout(resolve, 1)); } const second = service.provisionOrdinaryStack({ @@ -475,7 +498,7 @@ describe("ordinary-folder managed stack contract", () => { ports: [requested], serviceVersions: { postgres: "17.6.1.143" }, }); - service.close(); + await service.close(); }); it("rolls back failed initialization and makes the same start retryable", async () => { @@ -502,7 +525,7 @@ describe("ordinary-folder managed stack contract", () => { const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); expect(retried.outcome).toBe("create"); expect(service.listStacks()).toHaveLength(1); - service.close(); + await service.close(); }); it("rejects a copied ordinary-folder identity claim", async () => { @@ -521,8 +544,8 @@ describe("ordinary-folder managed stack contract", () => { service.provisionOrdinaryStack({ workspacePath: secondWorkspace }), ).rejects.toBeInstanceOf(DuplicateManagedIdentityError); expect(service.listStacks()).toHaveLength(1); - expect(service.repository.listCheckoutLocations()).toHaveLength(1); - service.close(); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); + await service.close(); }); it("times out without adopting a pending stack owned by another caller", async () => { @@ -538,7 +561,7 @@ describe("ordinary-folder managed stack contract", () => { service.provisionOrdinaryStack({ workspacePath: workspace }), ).rejects.toBeInstanceOf(ManagedStackPublicationTimeoutError); expect(service.listStacks()).toHaveLength(1); - service.close(); + await service.close(); }); it.each([0, -1, 1.5])( @@ -552,14 +575,14 @@ describe("ordinary-folder managed stack contract", () => { const workspace = makeWorkspace(root); const repository = createInMemoryManagedStackRepository(); let livenessProbes = 0; - const corruptedRepository: ManagedStackRepository = { + const corruptedRepository: ManagedStackRepositoryShape = { ...repository, - prepareOrdinaryStack(input) { - const prepared = repository.prepareOrdinaryStack(input); - return prepared.outcome === "existing" && prepared.operation !== undefined - ? { ...prepared, operation: { ...prepared.operation, ownerPid } } - : prepared; - }, + prepareOrdinaryStack: (input) => + Effect.map(repository.prepareOrdinaryStack(input), (prepared) => + prepared.outcome === "existing" && prepared.operation !== undefined + ? { ...prepared, operation: { ...prepared.operation, ownerPid } } + : prepared, + ), }; const service = makeManagedStackService({ repository: corruptedRepository, @@ -578,7 +601,7 @@ describe("ordinary-folder managed stack contract", () => { ).rejects.toBeInstanceOf(ManagedAbandonedOperationError); expect(livenessProbes).toBe(0); - service.close(); + await service.close(); }, ); @@ -587,12 +610,13 @@ describe("ordinary-folder managed stack contract", () => { const workspace = makeWorkspace(root); const repository = createInMemoryManagedStackRepository(); const pollTimes: Array = []; - const observedRepository: ManagedStackRepository = { + const observedRepository: ManagedStackRepositoryShape = { ...repository, - getStack(stackId) { - pollTimes.push(performance.now()); - return repository.getStack(stackId); - }, + getStack: (stackId) => + Effect.suspend(() => { + pollTimes.push(performance.now()); + return repository.getStack(stackId); + }), }; const service = makeManagedStackService({ repository: observedRepository, @@ -612,13 +636,13 @@ describe("ordinary-folder managed stack contract", () => { expect(pollTimes.length).toBeGreaterThanOrEqual(2); const gaps = pollTimes.slice(1).map((time, index) => time - (pollTimes[index] ?? 0)); expect(gaps.slice(0, 2).every((gap) => gap >= 350)).toBe(true); - service.close(); + await service.close(); }); it("rejects a non-UUID stack factory result before deriving state paths", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - await ensureOrdinaryWorkspaceIdentity(workspace); + await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace)); const service = makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), @@ -664,7 +688,7 @@ describe("managed service options", () => { makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: undefined, - } as unknown as ManagedStackServiceOptions), + } as unknown as MakeManagedStackServiceOptions), ).toThrow(UnsafeManagedStackPathError); expect(existsSync(configuredHome)).toBe(false); } finally { @@ -690,7 +714,7 @@ describe("managed service options", () => { }, ); - it("validates owner pids on the shared entrypoint options path too", () => { + it("validates owner pids on the shared entrypoint options path too", async () => { const root = makeRoot(); expect(() => createManagedStackService({ @@ -706,7 +730,7 @@ describe("managed service options", () => { ownerPid: 4321, }); expect(service.stateRoot).toBe(join(root, "managed")); - service.close(); + await service.close(); }); }); @@ -739,12 +763,12 @@ describe("managed repository and lifecycle", () => { [first.stack.id, second.stack.id].sort(), ); - const paths = service.repository - .listCheckoutLocations() - .map((location) => location.canonicalPath); + const paths = runRepo(service.repository.listCheckoutLocations()).map( + (location) => location.canonicalPath, + ); expect(paths).toEqual([...paths].sort()); expect(paths).toHaveLength(2); - service.close(); + await service.close(); }); } @@ -763,17 +787,17 @@ describe("managed repository and lifecycle", () => { expect(created.outcome).toBe("create"); expect(reused.outcome).toBe("reuse"); expect(reused.selection).toEqual(created.selection); - service.close(); + await service.close(); }); } - it("anchors an injected relative state root so a later chdir cannot split stack state", () => { + it("anchors an injected relative state root so a later chdir cannot split stack state", async () => { const service = makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: "relative-managed-state", }); expect(service.stateRoot).toBe(resolve("relative-managed-state")); - service.close(); + await service.close(); }); for (const adapter of ["in-memory", "bun-sqlite"] as const) { @@ -797,7 +821,7 @@ describe("managed repository and lifecycle", () => { }), ).rejects.toBeInstanceOf(InvalidManagedPortError); expect(service.inspectStack(created.stack.id)?.ports).toEqual([]); - service.close(); + await service.close(); }); } @@ -844,7 +868,7 @@ describe("managed repository and lifecycle", () => { }), ).rejects.toBeInstanceOf(ManagedPortReservationError); expect(service.inspectStack(second.stack.id)?.ports).toEqual([]); - service.close(); + await service.close(); }); it("rolls back an in-memory registration when its initial port reservation conflicts", async () => { @@ -868,12 +892,12 @@ describe("managed repository and lifecycle", () => { }, }), ).rejects.toBeInstanceOf(ManagedPortReservationError); - expect(service.repository.listCheckoutLocations()).toHaveLength(1); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); expect(service.listStacks()).toHaveLength(1); const retried = await service.provisionOrdinaryStack({ workspacePath: secondWorkspace }); expect(retried.outcome).toBe("create"); - expect(service.repository.listCheckoutLocations()).toHaveLength(2); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(2); }); it("requires actual runtime inspection before recovering an abandoned operation", async () => { @@ -882,22 +906,26 @@ describe("managed repository and lifecycle", () => { const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); - const claimed = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: created.stack.id, - kind: "start", - ownerPid: 987_654, - now: "2026-08-11T00:00:00.000Z", - }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!claimed.acquired) { throw new Error("Expected to claim an abandoned operation"); } - service.repository.updateStack({ - stackId: created.stack.id, - operationToken: claimed.operation.token, - lifecycle: "starting", - now: "2026-08-11T00:00:01.000Z", - }); + runRepo( + service.repository.updateStack({ + stackId: created.stack.id, + operationToken: claimed.operation.token, + lifecycle: "starting", + now: "2026-08-11T00:00:01.000Z", + }), + ); const unknown = await service.reconcileAbandonedOperations({ inspectRuntime: async () => "unknown", @@ -938,7 +966,7 @@ describe("managed repository and lifecycle", () => { const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); expect(retried.outcome).toBe("create"); expect(retried.stack.id).not.toBe(pending.stack.id); - service.close(); + await service.close(); }); it("publishes a crashed pending provision when runtime inspection finds it running", async () => { @@ -959,7 +987,7 @@ describe("managed repository and lifecycle", () => { const reused = await service.provisionOrdinaryStack({ workspacePath: workspace }); expect(reused.outcome).toBe("reuse"); expect(reused.stack.id).toBe(pending.stack.id); - service.close(); + await service.close(); }); it("retains operations while their owner process is still alive", async () => { @@ -1051,7 +1079,7 @@ describe("managed repository and lifecycle", () => { expect(staleTarget.abortedStackIds).toEqual([]); expect(inspected).toEqual([]); - expect(service.repository.listActiveOperations()).toHaveLength(3); + expect(runRepo(service.repository.listActiveOperations())).toHaveLength(3); const forced = await service.reconcileAbandonedOperations({ force: { @@ -1067,8 +1095,7 @@ describe("managed repository and lifecycle", () => { expect(inspected).toEqual([target.stack.id]); expect(forced.abortedStackIds).toEqual([target.stack.id]); expect( - service.repository - .listActiveOperations() + runRepo(service.repository.listActiveOperations()) .map(({ token }) => token) .sort(), ).toEqual( @@ -1112,11 +1139,13 @@ describe("managed repository and lifecycle", () => { const reconciled = await service.reconcileAbandonedOperations({ inspectRuntime: async (stack, operation) => { - service.repository.reconcileOperation( - stack.id, - operation.token, - "running", - "2026-08-11T00:00:01.000Z", + runRepo( + service.repository.reconcileOperation( + stack.id, + operation.token, + "running", + "2026-08-11T00:00:01.000Z", + ), ); return "stopped"; }, @@ -1130,7 +1159,7 @@ describe("managed repository and lifecycle", () => { lifecycle: "running", }); expect(readFileSync(dataFile, "utf8")).toBe("live data"); - service.close(); + await service.close(); }); for (const adapter of ["in-memory", "bun-sqlite"] as const) { @@ -1150,17 +1179,19 @@ describe("managed repository and lifecycle", () => { stackRoot = stack.paths.root; dataFile = join(stack.paths.data, "database"); writeFileSync(dataFile, "live data"); - const operation = service.repository - .listActiveOperations() - .find((candidate) => candidate.stackId === stack.id); + const operation = runRepo(service.repository.listActiveOperations()).find( + (candidate) => candidate.stackId === stack.id, + ); if (operation === undefined) { throw new Error("Expected the provision operation to remain active"); } - service.repository.reconcileOperation( - stack.id, - operation.token, - "running", - "2026-08-11T00:00:01.000Z", + runRepo( + service.repository.reconcileOperation( + stack.id, + operation.token, + "running", + "2026-08-11T00:00:01.000Z", + ), ); }, }), @@ -1175,7 +1206,7 @@ describe("managed repository and lifecycle", () => { expect(service.listStacks()).toEqual([ expect.objectContaining({ status: "active", lifecycle: "running" }), ]); - service.close(); + await service.close(); }); } @@ -1221,7 +1252,7 @@ describe("managed repository and lifecycle", () => { error: inspectionError, }, ]); - expect(service.repository.listActiveOperations()).toEqual([pending.operation]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([pending.operation]); }); it("reports a failed post-abort state reclamation", async () => { @@ -1229,23 +1260,22 @@ describe("managed repository and lifecycle", () => { const repository = createInMemoryManagedStackRepository(); let returnUnsafePath = false; const unsafeRoot = join(root, "outside"); - const guardedRepository: ManagedStackRepository = { + const guardedRepository: ManagedStackRepositoryShape = { ...repository, - getStack(stackId) { - const stack = repository.getStack(stackId); - if (stack === undefined || !returnUnsafePath) { - return stack; - } - return { - ...stack, - paths: { - root: unsafeRoot, - data: join(unsafeRoot, "data"), - logs: join(unsafeRoot, "logs"), - runtime: join(unsafeRoot, "runtime"), - }, - }; - }, + getStack: (stackId) => + Effect.map(repository.getStack(stackId), (stack) => + stack === undefined || !returnUnsafePath + ? stack + : { + ...stack, + paths: { + root: unsafeRoot, + data: join(unsafeRoot, "data"), + logs: join(unsafeRoot, "logs"), + runtime: join(unsafeRoot, "runtime"), + }, + }, + ), }; const service = makeManagedStackService({ repository: guardedRepository, @@ -1282,20 +1312,24 @@ describe("managed repository and lifecycle", () => { const second = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "second"), }); - const firstOperation = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: first.stack.id, - kind: "start", - ownerPid: 987_653, - now: "2026-08-11T00:00:00.000Z", - }); - const secondOperation = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: second.stack.id, - kind: "start", - ownerPid: 987_654, - now: "2026-08-11T00:00:01.000Z", - }); + const firstOperation = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: first.stack.id, + kind: "start", + ownerPid: 987_653, + now: "2026-08-11T00:00:00.000Z", + }), + ); + const secondOperation = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: second.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:01.000Z", + }), + ); if (!firstOperation.acquired || !secondOperation.acquired) { throw new Error("Expected both recovery operations to be claimed"); } @@ -1303,11 +1337,13 @@ describe("managed repository and lifecycle", () => { const reconciled = await service.reconcileAbandonedOperations({ inspectRuntime: async (stack, operation) => { if (stack.id === first.stack.id) { - service.repository.finishOperation( - stack.id, - operation.token, - "completed", - "2026-08-11T00:00:02.000Z", + runRepo( + service.repository.finishOperation( + stack.id, + operation.token, + "completed", + "2026-08-11T00:00:02.000Z", + ), ); } return "stopped"; @@ -1357,7 +1393,7 @@ describe("managed repository and lifecycle", () => { status: "pending", lifecycle: "stopped", }); - expect(service.repository.listActiveOperations()).toEqual([pending.operation]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([pending.operation]); await service.updateStack(owner.stack.id, { lifecycle: "stopped" }); const retried = await service.reconcileAbandonedOperations({ @@ -1372,8 +1408,8 @@ describe("managed repository and lifecycle", () => { }), ]); expect(retried.failures).toEqual([]); - expect(service.repository.listActiveOperations()).toEqual([]); - service.close(); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + await service.close(); }); } @@ -1398,13 +1434,15 @@ describe("managed repository and lifecycle", () => { ports: [{ key: "api.port", port: 55_410, intent: "exact" }], }, }); - const operation = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: blocked.stack.id, - kind: "start", - ownerPid: 987_654, - now: "2026-08-11T00:00:00.000Z", - }); + const operation = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: blocked.stack.id, + kind: "start", + ownerPid: 987_654, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!operation.acquired) { throw new Error("Expected the abandoned start operation to be claimed"); } @@ -1420,14 +1458,14 @@ describe("managed repository and lifecycle", () => { operationReleased: true, error: expect.any(ManagedPortReservationError), }); - expect(service.repository.listActiveOperations()).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); expect(service.inspectStack(blocked.stack.id)?.lifecycle).toBe("failed"); await expect( service.deleteStack(blocked.stack.id, { stop: async () => {} }), ).resolves.toMatchObject({ outcome: "delete", }); - service.close(); + await service.close(); }); } @@ -1467,7 +1505,7 @@ describe("managed repository and lifecycle", () => { }); expect(sticky.outcome).toBe("reuse"); expect(sticky.stack.ports).toEqual([{ ...requested, intent: "automatic" }]); - service.close(); + await service.close(); }); it("rejects port drift while running without overwriting persisted exact intent", async () => { @@ -1493,7 +1531,7 @@ describe("managed repository and lifecycle", () => { expect(service.inspectStack(created.stack.id)?.ports).toEqual([ { key: previous.key, port: previous.port, intent: previous.intent }, ]); - service.close(); + await service.close(); }); for (const adapter of ["in-memory", "bun-sqlite"] as const) { @@ -1538,7 +1576,7 @@ describe("managed repository and lifecycle", () => { lifecycle: "stopped", ports: [{ key: "api.port", port: 55_404, intent: "exact" }], }); - service.close(); + await service.close(); }); } @@ -1577,7 +1615,7 @@ describe("managed repository and lifecycle", () => { const startedSecond = await service.updateStack(second.stack.id, { lifecycle: "starting" }); expect(startedSecond.ports).toEqual([assignment]); expect(stickyContract.expected.outcome).toBe("reuse"); - service.close(); + await service.close(); }); it("reports duplicate ports inside one stack as a managed reservation error", async () => { @@ -1597,7 +1635,7 @@ describe("managed repository and lifecycle", () => { }), ).rejects.toBeInstanceOf(ManagedPortReservationError); expect(service.inspectStack(created.stack.id)?.ports).toEqual([]); - service.close(); + await service.close(); }); it("rejects a second operation claim without mutating the stack", async () => { @@ -1606,13 +1644,15 @@ describe("managed repository and lifecycle", () => { const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); - const claimed = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: created.stack.id, - kind: "start", - ownerPid: process.pid, - now: "2026-08-11T00:00:00.000Z", - }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!claimed.acquired) { throw new Error("Expected the first operation claim to succeed"); } @@ -1636,26 +1676,30 @@ describe("managed repository and lifecycle", () => { const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); - const claimed = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: created.stack.id, - kind: "update", - ownerPid: process.pid, - now: "2026-08-11T00:00:00.000Z", - }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "update", + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!claimed.acquired) { throw new Error("Expected the update operation to be claimed"); } expect(() => - service.repository.finishOperation( - created.stack.id, - crypto.randomUUID(), - "completed", - "2026-08-11T00:00:01.000Z", + runRepo( + service.repository.finishOperation( + created.stack.id, + crypto.randomUUID(), + "completed", + "2026-08-11T00:00:01.000Z", + ), ), ).toThrow(ManagedOperationOwnershipError); - service.close(); + await service.close(); }); } @@ -1680,14 +1724,14 @@ describe("managed repository and lifecycle", () => { lifecycle: "stopped", ports: [], }); - expect(service.repository.listActiveOperations()).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); const successor = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "successor"), configuration: { lifecycle: "running", ports: [reserved] }, }); expect(successor.stack.ports).toEqual([reserved]); - service.close(); + await service.close(); }); } @@ -1709,20 +1753,24 @@ describe("managed repository and lifecycle", () => { workspacePath: makeWorkspace(root), }); writeFileSync(join(created.stack.paths.data, "database"), "leaked"); - const claimed = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: created.stack.id, - kind: "delete", - ownerPid: 987_680, - now: "2026-08-11T00:00:00.000Z", - }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_680, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!claimed.acquired) { throw new Error("Expected the delete operation to be claimed"); } - service.repository.tombstoneStack( - created.stack.id, - claimed.operation.token, - "2026-08-11T00:00:01.000Z", + runRepo( + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ), ); const reconciled = await service.reconcileAbandonedOperations({ @@ -1734,7 +1782,7 @@ describe("managed repository and lifecycle", () => { expect(reconciled.abortedStackIds).toEqual([]); expect(reconciled.failures).toEqual([]); expect(reconciled.retained).toEqual([]); - expect(service.repository.listActiveOperations()).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); // The tombstone itself survives: idempotent deletion depends on it. expect(service.inspectStack(created.stack.id)).toMatchObject({ status: "tombstoned", @@ -1759,7 +1807,7 @@ describe("managed repository and lifecycle", () => { await expect(service.deleteStack(created.stack.id)).resolves.toMatchObject({ outcome: "no-op", }); - service.close(); + await service.close(); }); } } @@ -1780,20 +1828,24 @@ describe("managed repository and lifecycle", () => { workspacePath: makeWorkspace(root), }); writeFileSync(join(created.stack.paths.data, "database"), "leaked"); - const claimed = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: created.stack.id, - kind: "delete", - ownerPid: 987_681, - now: "2026-08-11T00:00:00.000Z", - }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_681, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!claimed.acquired) { throw new Error("Expected the delete operation to be claimed"); } - service.repository.tombstoneStack( - created.stack.id, - claimed.operation.token, - "2026-08-11T00:00:01.000Z", + runRepo( + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ), ); const reconciled = await service.reconcileAbandonedOperations({ @@ -1805,10 +1857,10 @@ describe("managed repository and lifecycle", () => { expect(reconciled.reclaimedStackIds).toEqual([created.stack.id]); expect(reconciled.retained).toEqual([]); expect(reconciled.failures).toEqual([]); - expect(service.repository.listActiveOperations()).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); expect(existsSync(created.stack.paths.root)).toBe(false); expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); - service.close(); + await service.close(); }); } @@ -1819,28 +1871,26 @@ describe("managed repository and lifecycle", () => { const outsideRoot = join(root, "outside"); mkdirSync(outsideRoot, { recursive: true }); writeFileSync(join(outsideRoot, "preserve"), "safe"); - const repository = - adapter === "in-memory" - ? createInMemoryManagedStackRepository() - : openBunSqliteManagedStackRepository(managedRegistryPath(stateRoot)); + const registry = + adapter === "in-memory" ? undefined : openRegistry(managedRegistryPath(stateRoot)); + const repository = registry?.repository ?? createInMemoryManagedStackRepository(); let forgePath = false; - const guardedRepository: ManagedStackRepository = { + const guardedRepository: ManagedStackRepositoryShape = { ...repository, - getStack(stackId) { - const stack = repository.getStack(stackId); - if (stack === undefined || !forgePath) { - return stack; - } - return { - ...stack, - paths: { - root: outsideRoot, - data: join(outsideRoot, "data"), - logs: join(outsideRoot, "logs"), - runtime: join(outsideRoot, "runtime"), - }, - }; - }, + getStack: (stackId) => + Effect.map(repository.getStack(stackId), (stack) => + stack === undefined || !forgePath + ? stack + : { + ...stack, + paths: { + root: outsideRoot, + data: join(outsideRoot, "data"), + logs: join(outsideRoot, "logs"), + runtime: join(outsideRoot, "runtime"), + }, + }, + ), }; const service = makeManagedStackService({ repository: guardedRepository, @@ -1850,20 +1900,24 @@ describe("managed repository and lifecycle", () => { const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); - const claimed = service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: created.stack.id, - kind: "delete", - ownerPid: 987_682, - now: "2026-08-11T00:00:00.000Z", - }); + const claimed = runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "delete", + ownerPid: 987_682, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!claimed.acquired) { throw new Error("Expected the delete operation to be claimed"); } - service.repository.tombstoneStack( - created.stack.id, - claimed.operation.token, - "2026-08-11T00:00:01.000Z", + runRepo( + service.repository.tombstoneStack( + created.stack.id, + claimed.operation.token, + "2026-08-11T00:00:01.000Z", + ), ); forgePath = true; @@ -1883,7 +1937,8 @@ describe("managed repository and lifecycle", () => { }, ]); expect(readFileSync(join(outsideRoot, "preserve"), "utf8")).toBe("safe"); - service.close(); + await service.close(); + await registry?.close(); }); } @@ -1912,7 +1967,7 @@ describe("managed repository and lifecycle", () => { expect(updated.ports).toEqual(sorted); expect(service.inspectStack(created.stack.id)?.ports).toEqual(sorted); - service.close(); + await service.close(); }); } @@ -1935,13 +1990,15 @@ describe("managed repository and lifecycle", () => { workspacePath: makeWorkspace(root, name), }); const token = nextToken(); - const claimed = service.repository.claimOperation({ - token, - stackId: created.stack.id, - kind: "start", - ownerPid: 987_683, - now: "2026-08-11T00:00:00.000Z", - }); + const claimed = runRepo( + service.repository.claimOperation({ + token, + stackId: created.stack.id, + kind: "start", + ownerPid: 987_683, + now: "2026-08-11T00:00:00.000Z", + }), + ); if (!claimed.acquired) { throw new Error("Expected each recovery operation to be claimed"); } @@ -1949,10 +2006,10 @@ describe("managed repository and lifecycle", () => { } expect(tokens).toEqual([...tokens].sort().reverse()); - expect(service.repository.listActiveOperations().map(({ token }) => token)).toEqual( + expect(runRepo(service.repository.listActiveOperations()).map(({ token }) => token)).toEqual( [...tokens].sort(), ); - service.close(); + await service.close(); }); } @@ -1970,22 +2027,24 @@ describe("managed repository and lifecycle", () => { for (const ownerPid of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { expect(() => - service.repository.claimOperation({ - token: crypto.randomUUID(), - stackId: created.stack.id, - kind: "start", - ownerPid, - now: "2026-08-11T00:00:00.000Z", - }), + runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid, + now: "2026-08-11T00:00:00.000Z", + }), + ), ).toThrow(InvalidManagedOwnerPidError); } - expect(service.repository.listActiveOperations()).toEqual([]); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); await expect( prepareAbandonedStack(service, makeWorkspace(root, "prepared"), 0), ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); expect(service.listStacks()).toHaveLength(1); - service.close(); + await service.close(); }); } @@ -2000,19 +2059,21 @@ describe("managed repository and lifecycle", () => { const pending = await prepareAbandonedStack(service, makeWorkspace(root), process.pid); expect(() => - service.repository.updateStack({ - stackId: pending.stack.id, - operationToken: pending.operation.token, - now: "2026-08-11T00:00:02.000Z", - lifecycle: "running", - }), + runRepo( + service.repository.updateStack({ + stackId: pending.stack.id, + operationToken: pending.operation.token, + now: "2026-08-11T00:00:02.000Z", + lifecycle: "running", + }), + ), ).toThrow(ManagedPendingStackUpdateError); expect(service.inspectStack(pending.stack.id)).toMatchObject({ status: "pending", lifecycle: "stopped", }); - service.close(); + await service.close(); }); } @@ -2034,8 +2095,8 @@ describe("managed repository and lifecycle", () => { status: "active", lifecycle: "running", }); - expect(service.repository.listActiveOperations()).toEqual([]); - service.close(); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + await service.close(); }); } @@ -2043,31 +2104,43 @@ describe("managed repository and lifecycle", () => { const root = makeRoot(); const repository = createInMemoryManagedStackRepository(); let promoteBeforeDelete = true; - const racingRepository: ManagedStackRepository = { + const racingRepository: ManagedStackRepositoryShape = { ...repository, - claimOperation(input) { - if (input.kind === "delete" && promoteBeforeDelete) { - promoteBeforeDelete = false; - const start = repository.claimOperation({ - token: crypto.randomUUID(), - stackId: input.stackId, - kind: "start", - ownerPid: 123, - now: input.now, - }); - if (!start.acquired) { - throw new Error("Expected the racing start operation to be claimed"); + claimOperation: (input) => + Effect.suspend(() => { + if (input.kind === "delete" && promoteBeforeDelete) { + promoteBeforeDelete = false; + const start = runRepo( + repository.claimOperation({ + token: crypto.randomUUID(), + stackId: input.stackId, + kind: "start", + ownerPid: 123, + now: input.now, + }), + ); + if (!start.acquired) { + throw new Error("Expected the racing start operation to be claimed"); + } + runRepo( + repository.updateStack({ + stackId: input.stackId, + operationToken: start.operation.token, + lifecycle: "running", + now: input.now, + }), + ); + runRepo( + repository.finishOperation( + input.stackId, + start.operation.token, + "completed", + input.now, + ), + ); } - repository.updateStack({ - stackId: input.stackId, - operationToken: start.operation.token, - lifecycle: "running", - now: input.now, - }); - repository.finishOperation(input.stackId, start.operation.token, "completed", input.now); - } - return repository.claimOperation(input); - }, + return repository.claimOperation(input); + }), }; const service = makeManagedStackService({ repository: racingRepository, @@ -2094,14 +2167,12 @@ describe("managed repository and lifecycle", () => { // claim first must not turn an already-completed delete into a failure. const root = makeRoot(); const repository = createInMemoryManagedStackRepository(); - const racingRepository: ManagedStackRepository = { + const racingRepository: ManagedStackRepositoryShape = { ...repository, - finishOperation(stackId, operationToken, outcome, now, error) { - if (outcome === "completed") { - throw new ManagedOperationOwnershipError({ stackId }); - } - repository.finishOperation(stackId, operationToken, outcome, now, error); - }, + finishOperation: (stackId, operationToken, outcome, now, error) => + outcome === "completed" + ? Effect.fail(new ManagedOperationOwnershipError({ stackId })) + : repository.finishOperation(stackId, operationToken, outcome, now, error), }; const service = makeManagedStackService({ repository: racingRepository, @@ -2118,7 +2189,7 @@ describe("managed repository and lifecycle", () => { dataReclamation: { outcome: "removed" }, }); expect(existsSync(created.stack.paths.root)).toBe(false); - service.close(); + await service.close(); }); it("stops, tombstones, and reclaims one opaque stack ID idempotently", async () => { @@ -2149,7 +2220,7 @@ describe("managed repository and lifecycle", () => { expect(repeated.dataReclamation).toEqual({ outcome: "removed" }); expect(service.listStacks()).toEqual([]); expect(service.listStacks({ includeTombstoned: true })).toHaveLength(1); - service.close(); + await service.close(); }); it("reports unsafe tombstone data as retained without deleting it", async () => { @@ -2159,23 +2230,22 @@ describe("managed repository and lifecycle", () => { const outsideRoot = join(root, "outside"); mkdirSync(outsideRoot); writeFileSync(join(outsideRoot, "preserve"), "safe"); - const guardedRepository: ManagedStackRepository = { + const guardedRepository: ManagedStackRepositoryShape = { ...repository, - getStack(stackId) { - const stack = repository.getStack(stackId); - if (stack === undefined || !forgePath) { - return stack; - } - return { - ...stack, - paths: { - root: outsideRoot, - data: join(outsideRoot, "data"), - logs: join(outsideRoot, "logs"), - runtime: join(outsideRoot, "runtime"), - }, - }; - }, + getStack: (stackId) => + Effect.map(repository.getStack(stackId), (stack) => + stack === undefined || !forgePath + ? stack + : { + ...stack, + paths: { + root: outsideRoot, + data: join(outsideRoot, "data"), + logs: join(outsideRoot, "logs"), + runtime: join(outsideRoot, "runtime"), + }, + }, + ), }; const service = makeManagedStackService({ repository: guardedRepository, @@ -2213,29 +2283,29 @@ describe("managed repository and lifecycle", () => { expect(contract.expected.outcome).toBe("update"); expect(pruned).toBe(1); - expect(service.repository.listCheckoutLocations()).toEqual([]); + expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); expect(service.inspectStack(created.stack.id)?.status).toBe("active"); expect(readFileSync(dataFile, "utf8")).toBe("preserve me"); - service.close(); + await service.close(); }); it("persists and reuses managed state through the real Node SQLite adapter", async () => { const root = makeRoot(); const stateRoot = join(root, "node-managed"); const workspace = makeWorkspace(root, "node-workspace"); - const adapterUrl = pathToFileURL(join(process.cwd(), "src/managed/sqlite-node.ts")).href; - const serviceUrl = pathToFileURL(join(process.cwd(), "src/managed/service.ts")).href; + // The Node entrypoint is exercised end to end, `node:sqlite` driver and all: + // it is the only place the Node registry adapter and its service wiring run. + const entrypointUrl = pathToFileURL(join(process.cwd(), "src/managed-node.ts")).href; const source = ` import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; - import { openNodeSqliteManagedStackRepository } from ${JSON.stringify(adapterUrl)}; - import { makeManagedStackService } from ${JSON.stringify(serviceUrl)}; + import { Effect } from "effect"; + import { createManagedStackService } from ${JSON.stringify(entrypointUrl)}; + const runRepo = Effect.runSync; const stateRoot = ${JSON.stringify(stateRoot)}; const workspacePath = ${JSON.stringify(workspace)}; - const databasePath = ${JSON.stringify(managedRegistryPath(stateRoot))}; - const firstRepository = openNodeSqliteManagedStackRepository(databasePath); - assert.equal(firstRepository.getStack(randomUUID()), undefined); - const firstService = makeManagedStackService({ repository: firstRepository, stateRoot }); + const firstService = createManagedStackService({ stateRoot }); + assert.equal(runRepo(firstService.repository.getStack(randomUUID())), undefined); const first = await firstService.provisionOrdinaryStack({ workspacePath, configuration: { @@ -2245,50 +2315,49 @@ describe("managed repository and lifecycle", () => { const starting = await firstService.updateStack(first.stack.id, { lifecycle: "starting" }); assert.equal(starting.ports[0]?.port, 55431); await firstService.updateStack(first.stack.id, { lifecycle: "stopped" }); - const abandoned = firstRepository.claimOperation({ + const abandoned = runRepo(firstService.repository.claimOperation({ token: randomUUID(), stackId: first.stack.id, kind: "start", now: new Date().toISOString(), - }); + })); assert.equal(abandoned.acquired, true); const recovery = await firstService.reconcileAbandonedOperations({ inspectRuntime: async () => "stopped", }); assert.equal(recovery.recovered.length, 1); assert.equal(recovery.failures.length, 0); - firstService.close(); - const secondRepository = openNodeSqliteManagedStackRepository(databasePath); - const secondService = makeManagedStackService({ repository: secondRepository, stateRoot }); + await firstService.close(); + const secondService = createManagedStackService({ stateRoot }); const second = await secondService.provisionOrdinaryStack({ workspacePath }); assert.equal(first.outcome, "create"); assert.equal(second.outcome, "reuse"); assert.equal(second.stack.id, first.stack.id); - const conflicting = secondRepository.claimOperation({ + const conflicting = runRepo(secondService.repository.claimOperation({ token: randomUUID(), stackId: second.stack.id, kind: "update", ownerPid: process.pid, now: new Date().toISOString(), - }); + })); assert.equal(conflicting.acquired, true); await assert.rejects( secondService.updateStack(second.stack.id, { lifecycle: "running" }), { name: "ManagedOperationInProgressError" }, ); if (!conflicting.acquired) throw new Error("Expected operation ownership"); - secondRepository.finishOperation( + runRepo(secondService.repository.finishOperation( second.stack.id, conflicting.operation.token, "completed", new Date().toISOString(), - ); + )); const deleted = await secondService.deleteStack(second.stack.id); const repeated = await secondService.deleteStack(second.stack.id); assert.equal(deleted.outcome, "delete"); assert.equal(deleted.dataReclamation.outcome, "removed"); assert.equal(repeated.outcome, "no-op"); - secondService.close(); + await secondService.close(); `; const command = [ findNodeBinary(), @@ -2312,12 +2381,18 @@ describe("managed repository and lifecycle", () => { it("initializes one fresh registry safely across concurrent Bun processes", async () => { const root = makeRoot(); const databasePath = managedRegistryPath(join(root, "cold")); - const adapterUrl = pathToFileURL(join(process.cwd(), "src/managed/sqlite-bun.ts")).href; + const entrypointUrl = pathToFileURL(join(process.cwd(), "src/managed-bun.ts")).href; const source = ` - import { openBunSqliteManagedStackRepository } from ${JSON.stringify(adapterUrl)}; - const repository = openBunSqliteManagedStackRepository(${JSON.stringify(databasePath)}); - repository.listStacks(); - repository.close(); + import { Context, Effect, ManagedRuntime } from "effect"; + import { + bunSqliteManagedStackRepositoryLayer, + ManagedStackRepository, + } from ${JSON.stringify(entrypointUrl)}; + const layer = bunSqliteManagedStackRepositoryLayer(${JSON.stringify(databasePath)}); + const runtime = ManagedRuntime.make(layer); + const context = Effect.runSync(runtime.contextEffect); + Effect.runSync(Context.get(context, ManagedStackRepository).listStacks()); + await runtime.dispose(); `; const children = Array.from({ length: 8 }, () => Bun.spawn([process.execPath, "--eval", source], { stdout: "ignore", stderr: "pipe" }), @@ -2331,9 +2406,9 @@ describe("managed repository and lifecycle", () => { ); expect(results).toEqual(Array.from({ length: 8 }, () => ({ exitCode: 0, stderr: "" }))); - const repository = openBunSqliteManagedStackRepository(databasePath); - expect(repository.listStacks()).toEqual([]); - repository.close(); + const registry = openRegistry(databasePath); + expect(runRepo(registry.repository.listStacks())).toEqual([]); + await registry.close(); }); it("fails safely when a registry has a newer schema version", () => { @@ -2343,9 +2418,7 @@ describe("managed repository and lifecycle", () => { database.exec("PRAGMA user_version = 999"); database.close(); - expect(() => openBunSqliteManagedStackRepository(databasePath)).toThrow( - UnsupportedManagedRegistryVersionError, - ); + expect(() => openRegistry(databasePath)).toThrow(UnsupportedManagedRegistryVersionError); }); it.each([1, 2])("fails clearly instead of opening obsolete development schema v%i", (version) => { @@ -2355,16 +2428,13 @@ describe("managed repository and lifecycle", () => { database.exec(`PRAGMA user_version = ${version}`); database.close(); - expect(() => openBunSqliteManagedStackRepository(databasePath)).toThrow( - UnsupportedManagedRegistryVersionError, - ); + expect(() => openRegistry(databasePath)).toThrow(UnsupportedManagedRegistryVersionError); }); - it("writes the current schema version into a fresh registry", () => { + it("writes the current schema version into a fresh registry", async () => { const root = makeRoot(); const databasePath = managedRegistryPath(join(root, "fresh")); - const repository = openBunSqliteManagedStackRepository(databasePath); - repository.close(); + await openRegistry(databasePath).close(); const database = new Database(databasePath, { readonly: true }); expect(database.query("PRAGMA user_version").get()).toEqual({ diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts index 784ab97b33..c10a8ca4e4 100644 --- a/packages/stack/src/managed.ts +++ b/packages/stack/src/managed.ts @@ -7,12 +7,25 @@ export * from "./managed/service.ts"; // helpers behind it are invariants the adapters share with each other, not API // consumers can call meaningfully, and the in-memory adapter is a test seam // exported through `@supabase/stack/testing` instead. +export { ManagedStackRepository } from "./managed/repository.ts"; export type { + ClaimManagedOperationFailure, ClaimManagedOperationInput, ClaimManagedOperationResult, - ManagedStackRepository, + ManagedStackRepositoryShape, + OwnedManagedStackFailure, + PrepareOrdinaryStackFailure, PrepareOrdinaryStackInput, PrepareOrdinaryStackResult, + ReconcileManagedOperationFailure, ReconcileManagedOperationResult, + UpdateManagedStackFailure, UpdateManagedStackInput, } from "./managed/repository.ts"; +export type { + CreateManagedStackServiceOptions, + MakeManagedStackServiceOptions, + ManagedStackServiceHandle, + ProvisionOrdinaryStackRequest, + ReconcileAbandonedOperationsRequest, +} from "./managed/create-service.ts"; diff --git a/packages/stack/src/managed/create-service.ts b/packages/stack/src/managed/create-service.ts index 4f6caa8989..8f5e31dc59 100644 --- a/packages/stack/src/managed/create-service.ts +++ b/packages/stack/src/managed/create-service.ts @@ -1,10 +1,34 @@ -import { managedRegistryPath, resolveManagedStateRoot } from "./paths.ts"; -import type { ManagedStackRepository } from "./repository.ts"; -import { makeManagedStackService, type ManagedStackService } from "./service.ts"; +import { Context, Effect, Layer, ManagedRuntime, type FileSystem } from "effect"; +import type { + ManagedCheckoutLocation, + ManagedOperationRecord, + ManagedStackConfiguration, + ManagedStackRecord, + UnsupportedManagedRegistryVersionError, +} from "./model.ts"; +import { + managedRegistryPath, + requireExplicitManagedStateRoot, + resolveManagedStateRoot, +} from "./paths.ts"; +import { assertManagedOwnerPid, ManagedStackRepository } from "./repository.ts"; +import type { ManagedStackRepositoryShape } from "./repository.ts"; +import { + ManagedStackService, + type DeleteManagedStackResult, + type InspectOrdinaryWorkspaceResult, + type ManagedStackServiceOptions, + type ProvisionOrdinaryStackResult, + type ReconcileAbandonedOperationsResult, +} from "./service.ts"; + +export interface MakeManagedStackServiceOptions extends ManagedStackServiceOptions { + readonly repository: ManagedStackRepositoryShape; +} export interface CreateManagedStackServiceOptions { readonly stateRoot?: string; - readonly repository?: ManagedStackRepository; + readonly repository?: ManagedStackRepositoryShape; readonly env?: Readonly>; readonly homeDir?: string; readonly platform?: NodeJS.Platform; @@ -16,6 +40,174 @@ export interface CreateManagedStackServiceOptions { readonly isProcessAlive?: (pid: number) => boolean | Promise; } +export interface ProvisionOrdinaryStackRequest { + readonly workspacePath: string; + readonly stackName?: string; + readonly configuration?: ManagedStackConfiguration; + readonly initialize?: (stack: ManagedStackRecord) => Promise; + readonly validate?: (stack: ManagedStackRecord) => Promise; +} + +export type ReconcileAbandonedOperationsRequest = { + readonly inspectRuntime: ( + stack: ManagedStackRecord, + operation: ManagedOperationRecord, + ) => Promise<"running" | "stopped" | "unknown">; +} & ( + | { readonly startedBefore?: string; readonly force?: never } + | { + readonly startedBefore?: never; + readonly force: { readonly stackId: string; readonly operationToken: string }; + } +); + +/** + * The managed registry as a Promise API. + * + * `inspectStack` and `listStacks` stay synchronous accessors: the registry is a + * synchronous handle, and callers use them inline while deciding what to do next. + */ +export interface ManagedStackServiceHandle { + readonly stateRoot: string; + readonly repository: ManagedStackRepositoryShape; + provisionOrdinaryStack( + options: ProvisionOrdinaryStackRequest, + ): Promise; + inspectOrdinaryWorkspace(workspacePath: string): Promise; + inspectStack(stackId: string): ManagedStackRecord | undefined; + listStacks(options?: { readonly includeTombstoned?: boolean }): ReadonlyArray; + updateStack( + stackId: string, + configuration: ManagedStackConfiguration, + ): Promise; + deleteStack( + stackId: string, + options?: { readonly stop?: (stack: ManagedStackRecord) => Promise }, + ): Promise; + reconcileAbandonedOperations( + options: ReconcileAbandonedOperationsRequest, + ): Promise; + pruneCheckoutLocations( + shouldPrune: (location: ManagedCheckoutLocation) => boolean | Promise, + ): Promise; + close(): Promise; +} + +/** + * A caller-supplied callback may answer synchronously, asynchronously, or by + * throwing either way. Whatever it does becomes this effect's outcome unchanged, + * so the service's own handling of a failed callback is the same as it was when + * the service awaited promises directly. + */ +const fromCallback = (run: () => A | Promise): Effect.Effect => + Effect.flatMap(Effect.try({ try: run, catch: (error: unknown) => error }), (answer) => + answer instanceof Promise + ? Effect.tryPromise({ try: () => answer, catch: (error: unknown) => error }) + : Effect.succeed(answer), + ); + +const managedStackServiceHandle = ( + layer: Layer.Layer, +): ManagedStackServiceHandle => { + const runtime = ManagedRuntime.make(layer); + // Built eagerly and synchronously: the registry is a synchronous handle, the + // facade exposes synchronous reads over it, and a registry this process cannot + // open must fail while the service is being created rather than at whichever + // call happens to touch it first. + const context = Effect.runSync(runtime.contextEffect); + const service = Context.get(context, ManagedStackService); + const repository = Context.get(context, ManagedStackRepository); + + return { + stateRoot: service.stateRoot, + repository, + provisionOrdinaryStack: (options) => { + const initialize = options.initialize; + const validate = options.validate; + return runtime.runPromise( + service.provisionOrdinaryStack({ + workspacePath: options.workspacePath, + stackName: options.stackName, + configuration: options.configuration, + initialize: + initialize === undefined ? undefined : (stack) => fromCallback(() => initialize(stack)), + validate: + validate === undefined ? undefined : (stack) => fromCallback(() => validate(stack)), + }), + ); + }, + inspectOrdinaryWorkspace: (workspacePath) => + runtime.runPromise(service.inspectOrdinaryWorkspace(workspacePath)), + inspectStack: (stackId) => runtime.runSync(service.inspectStack(stackId)), + listStacks: (options) => runtime.runSync(service.listStacks(options)), + updateStack: (stackId, configuration) => + runtime.runPromise(service.updateStack(stackId, configuration)), + deleteStack: (stackId, options) => { + const stop = options?.stop; + return runtime.runPromise( + service.deleteStack(stackId, { + stop: stop === undefined ? undefined : (stack) => fromCallback(() => stop(stack)), + }), + ); + }, + reconcileAbandonedOperations: (options) => { + const inspectRuntime = (stack: ManagedStackRecord, operation: ManagedOperationRecord) => + fromCallback(() => options.inspectRuntime(stack, operation)); + return runtime.runPromise( + service.reconcileAbandonedOperations( + options.force === undefined + ? { inspectRuntime, startedBefore: options.startedBefore } + : { inspectRuntime, force: options.force }, + ), + ); + }, + pruneCheckoutLocations: (shouldPrune) => + runtime.runPromise( + service.pruneCheckoutLocations((location) => fromCallback(() => shouldPrune(location))), + ), + close: () => runtime.dispose(), + }; +}; + +const serviceLayer = ( + options: ManagedStackServiceOptions, + repositoryLayer: Layer.Layer, + fileSystemLayer: Layer.Layer, +): Layer.Layer< + ManagedStackRepository | ManagedStackService, + UnsupportedManagedRegistryVersionError +> => + ManagedStackService.make(options).pipe( + // Merged rather than only provided: the facade hands the very repository the + // service uses back to its caller, so an embedder can read the registry + // without opening a second handle on it. + Layer.provideMerge(repositoryLayer), + Layer.provide(fileSystemLayer), + Layer.orDie, + ); + +/** + * A managed stack service over a repository the caller already has. + * + * The state root and owner pid are validated here, before any layer is built, so + * a caller that supplied neither a usable root nor a usable pid learns about it + * from the call that made the mistake. + */ +export const makeManagedStackServiceWith = ( + fileSystemLayer: Layer.Layer, + options: MakeManagedStackServiceOptions, +): ManagedStackServiceHandle => { + const stateRoot = requireExplicitManagedStateRoot(options.stateRoot); + assertManagedOwnerPid(options.ownerPid); + return managedStackServiceHandle( + serviceLayer( + { ...options, stateRoot }, + Layer.succeed(ManagedStackRepository, options.repository), + fileSystemLayer, + ), + ); +}; + /** * The whole body of every runtime entrypoint's `createManagedStackService`, * parameterized only by how a registry file is opened. Keeping it here — rather @@ -24,19 +216,22 @@ export interface CreateManagedStackServiceOptions { * plumbing that the Node entry (which imports `node:sqlite`) shares. */ export const createManagedStackServiceWith = ( - openRepository: (registryPath: string) => ManagedStackRepository, + fileSystemLayer: Layer.Layer, + openRepository: ( + registryPath: string, + ) => Layer.Layer, options: CreateManagedStackServiceOptions, -): ManagedStackService => { +): ManagedStackServiceHandle => { const stateRoot = resolveManagedStateRoot(options); - const repository = options.repository ?? openRepository(managedRegistryPath(stateRoot)); - return makeManagedStackService({ - repository, - stateRoot, - idFactory: options.idFactory, - clock: options.clock, - ownerPid: options.ownerPid, - publicationTimeoutMs: options.publicationTimeoutMs, - publicationPollMs: options.publicationPollMs, - isProcessAlive: options.isProcessAlive, - }); + assertManagedOwnerPid(options.ownerPid); + const repository = options.repository; + return managedStackServiceHandle( + serviceLayer( + { ...options, stateRoot }, + repository === undefined + ? openRepository(managedRegistryPath(stateRoot)) + : Layer.succeed(ManagedStackRepository, repository), + fileSystemLayer, + ), + ); }; diff --git a/packages/stack/src/managed/failure.ts b/packages/stack/src/managed/failure.ts new file mode 100644 index 0000000000..453854533b --- /dev/null +++ b/packages/stack/src/managed/failure.ts @@ -0,0 +1,43 @@ +/** + * The managed guards in `ids.ts`, `paths.ts`, and `repository.ts` are pure + * synchronous functions that throw their own tagged failures, and both registry + * adapters drive synchronous SQLite or in-memory code that raises those same + * failures. Wrapping such a call with `Effect.try` therefore only has to + * recognize the failures the call site actually expects. + * + * Rethrowing anything else is deliberate: `Effect.try` treats a `catch` handler + * that throws as a defect, so a corrupt registry row or a decoder bug stays a + * defect instead of widening a method's error channel to `unknown`. + * + * The expected union must be named explicitly, because TypeScript infers a + * single class from a variadic list of unrelated constructors instead of + * unioning them: + * + * ```ts + * Effect.try({ + * try: () => repository.publish(stackId), + * catch: failsWith( + * ManagedOperationOwnershipError, + * ManagedStackNotFoundError, + * ), + * }) + * ``` + */ +export const failsWith = + (...failures: ReadonlyArray E>) => + (error: unknown): E => { + for (const failure of failures) { + if (error instanceof failure) { + return error; + } + } + throw error; + }; + +/** + * The `catch` handler for a synchronous call that has no domain failure at all: + * every throw is a defect. + */ +export const neverFails = (error: unknown): never => { + throw error; +}; diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index 466c58e2a7..baa65b58ec 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { link, mkdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; +import { Effect } from "effect"; import { InvalidManagedIdentityError, ORDINARY_WORKSPACE_IDENTITY_VERSION, @@ -8,8 +9,17 @@ import { } from "./model.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; import { errorCode } from "./error-code.ts"; +import { failsWith } from "./failure.ts"; import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; +/** + * The marker's own failures are the only ones this module reports. Filesystem + * errors that are not part of the identity protocol — an unreadable workspace, a + * full disk — are defects: no caller can act on them, and inventing an identity + * failure for them would hide what actually went wrong. + */ +const failsWithIdentity = failsWith(InvalidManagedIdentityError); + const identityField = (value: unknown, field: string): string => { if (typeof value !== "object" || value === null) { throw new InvalidManagedIdentityError({ @@ -51,15 +61,21 @@ const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { }; }; -export const canonicalizeOrdinaryWorkspacePath = async (workspacePath: string): Promise => { - const info = await stat(workspacePath); - if (!info.isDirectory()) { - throw new InvalidManagedIdentityError({ message: `${workspacePath} is not a directory` }); - } - return realpath(workspacePath); -}; +export const canonicalizeOrdinaryWorkspacePath = ( + workspacePath: string, +): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const info = await stat(workspacePath); + if (!info.isDirectory()) { + throw new InvalidManagedIdentityError({ message: `${workspacePath} is not a directory` }); + } + return realpath(workspacePath); + }, + catch: failsWithIdentity, + }); -export const readOrdinaryWorkspaceIdentity = async ( +const readIdentity = async ( workspacePath: string, ): Promise => { const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); @@ -73,17 +89,30 @@ export const readOrdinaryWorkspaceIdentity = async ( } }; +export const readOrdinaryWorkspaceIdentity = ( + workspacePath: string, +): Effect.Effect => + Effect.tryPromise({ try: () => readIdentity(workspacePath), catch: failsWithIdentity }); + export interface EnsureOrdinaryWorkspaceIdentityResult { readonly identity: OrdinaryWorkspaceIdentity; readonly created: boolean; readonly markerPath: string; } -export const ensureOrdinaryWorkspaceIdentity = async ( +/** + * Claiming a workspace stays one `await` chain rather than an `Effect.gen` + * pipeline: the temporary file, the hardlink that makes the claim atomic, its + * `EEXIST` re-read of the winning marker, and the `finally` that removes the + * temporary path are a single indivisible protocol. Interleaving it with other + * work — or interrupting it between the link and the cleanup — could leave a + * workspace holding a stray temporary marker. + */ +const ensureIdentity = async ( workspacePath: string, - idFactory: () => string = randomUUID, + idFactory: () => string, ): Promise => { - const existing = await readOrdinaryWorkspaceIdentity(workspacePath); + const existing = await readIdentity(workspacePath); const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); if (existing !== undefined) { return { identity: existing, created: false, markerPath }; @@ -106,7 +135,7 @@ export const ensureOrdinaryWorkspaceIdentity = async ( if (errorCode(error) !== "EEXIST") { throw error; } - const winner = await readOrdinaryWorkspaceIdentity(workspacePath); + const winner = await readIdentity(workspacePath); if (winner === undefined) { throw new InvalidManagedIdentityError({ message: "Identity publication raced without a winning marker", @@ -117,3 +146,12 @@ export const ensureOrdinaryWorkspaceIdentity = async ( await unlink(temporaryPath).catch(() => undefined); } }; + +export const ensureOrdinaryWorkspaceIdentity = ( + workspacePath: string, + idFactory: () => string = randomUUID, +): Effect.Effect => + Effect.tryPromise({ + try: () => ensureIdentity(workspacePath, idFactory), + catch: failsWithIdentity, + }); diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index fa0a9b1198..a34838f3a6 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -74,6 +74,26 @@ export const resolveManagedStateRoot = (options: ManagedStateRootOptions = {}): ); }; +/** + * The state root a managed stack service must be started with. + * + * `stateRoot` is required wherever a service is built, but a caller bypassing + * the type system (or a plain-JS caller) could still pass `undefined`, which + * would make {@link resolveManagedStateRoot} silently fall back to + * `SUPABASE_HOME` or the user's home directory instead of failing loudly. A root + * is a decision the caller owes the service, so a missing one is refused here + * rather than guessed. + */ +export const requireExplicitManagedStateRoot = (stateRoot: string | undefined): string => { + if (stateRoot === undefined) { + throw new UnsafeManagedStackPathError({ + path: String(stateRoot), + reason: "Refusing to start a managed stack service without an explicit state root", + }); + } + return resolveManagedStateRoot({ stateRoot }); +}; + export const managedRegistryPath = (stateRoot: string): string => join(stateRoot, "registry-v3.sqlite3"); diff --git a/packages/stack/src/managed/repository-memory.ts b/packages/stack/src/managed/repository-memory.ts index 59c85f28d6..6bacf331a3 100644 --- a/packages/stack/src/managed/repository-memory.ts +++ b/packages/stack/src/managed/repository-memory.ts @@ -1,7 +1,12 @@ +import { Effect } from "effect"; import { DuplicateManagedIdentityError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, ManagedPortReservationError, + ManagedRunningStackPortChangeError, ManagedStackNotFoundError, type ManagedCheckoutLocation, type ManagedOperationRecord, @@ -9,6 +14,7 @@ import { type ManagedStackConfiguration, type ManagedStackRecord, } from "./model.ts"; +import { failsWith } from "./failure.ts"; import { assertManagedOwnerPid, assertManagedStackUpdatable, @@ -16,9 +22,18 @@ import { managedStackOccupiesPorts, reconcileManagedPortAssignments, validateManagedPortAssignments, + type ClaimManagedOperationFailure, type ClaimManagedOperationInput, type ClaimManagedOperationResult, - type ManagedStackRepository, + type ManagedStackRepositoryShape, + type OwnedManagedStackFailure, + type PrepareOrdinaryStackFailure, + type PrepareOrdinaryStackInput, + type PrepareOrdinaryStackResult, + type ReconcileManagedOperationFailure, + type ReconcileManagedOperationResult, + type UpdateManagedStackFailure, + type UpdateManagedStackInput, } from "./repository.ts"; interface InMemoryCheckout { @@ -66,8 +81,12 @@ const emptyRuntimeMetadata = (): ManagedRuntimeMetadata => ({ * consumers exercise the managed service without a SQLite driver, and it is the * parity reference the persistent adapters are tested against. Production code * must go through a persistent adapter instead. + * + * The registry decisions themselves stay synchronous — the store is a set of + * maps, and {@link atomic} rolls them back by snapshot — so each contract method + * is that synchronous decision lifted into an `Effect`. */ -export const createInMemoryManagedStackRepository = (): ManagedStackRepository => { +export const createInMemoryManagedStackRepository = (): ManagedStackRepositoryShape => { const projects = new Set(); const checkouts = new Map(); const contexts = new Map(); @@ -210,182 +229,317 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = return { acquired: true, operation: copy(operation) }; }; - return { - prepareOrdinaryStack(input) { - assertManagedOwnerPid(input.ownerPid); - return atomic(() => { - projects.add(input.identity.projectId); - const checkout = checkouts.get(input.identity.checkoutId); - if (checkout !== undefined && checkout.projectId !== input.identity.projectId) { - throw new DuplicateManagedIdentityError({ - identityId: input.identity.checkoutId, - existingClaim: checkout.projectId, - requestedClaim: input.identity.projectId, - }); - } - checkouts.set(input.identity.checkoutId, { - id: input.identity.checkoutId, - projectId: input.identity.projectId, + const prepareOrdinaryStack = (input: PrepareOrdinaryStackInput): PrepareOrdinaryStackResult => { + assertManagedOwnerPid(input.ownerPid); + return atomic(() => { + projects.add(input.identity.projectId); + const checkout = checkouts.get(input.identity.checkoutId); + if (checkout !== undefined && checkout.projectId !== input.identity.projectId) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: checkout.projectId, + requestedClaim: input.identity.projectId, }); + } + checkouts.set(input.identity.checkoutId, { + id: input.identity.checkoutId, + projectId: input.identity.projectId, + }); - const context = contexts.get(input.identity.contextId); - if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { - throw new DuplicateManagedIdentityError({ - identityId: input.identity.contextId, - existingClaim: context.checkoutId, - requestedClaim: input.identity.checkoutId, - }); - } - contexts.set(input.identity.contextId, { - id: input.identity.contextId, - checkoutId: input.identity.checkoutId, + const context = contexts.get(input.identity.contextId); + if (context !== undefined && context.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.contextId, + existingClaim: context.checkoutId, + requestedClaim: input.identity.checkoutId, }); + } + contexts.set(input.identity.contextId, { + id: input.identity.contextId, + checkoutId: input.identity.checkoutId, + }); - const existingLocation = [...locations.values()].find( - (location) => location.checkoutId === input.identity.checkoutId, - ); - if ( - existingLocation !== undefined && - existingLocation.canonicalPath !== input.canonicalPath - ) { - throw new DuplicateManagedIdentityError({ - identityId: input.identity.checkoutId, - existingClaim: existingLocation.canonicalPath, - requestedClaim: input.canonicalPath, - }); - } - const pathOwner = [...locations.values()].find( - (location) => location.canonicalPath === input.canonicalPath, - ); - if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { - throw new DuplicateManagedIdentityError({ - identityId: input.canonicalPath, - existingClaim: pathOwner.checkoutId, - requestedClaim: input.identity.checkoutId, - }); - } - locations.set(existingLocation?.id ?? input.locationId, { - id: existingLocation?.id ?? input.locationId, - checkoutId: input.identity.checkoutId, - canonicalPath: input.canonicalPath, - lastSeenAt: input.now, + const existingLocation = [...locations.values()].find( + (location) => location.checkoutId === input.identity.checkoutId, + ); + if ( + existingLocation !== undefined && + existingLocation.canonicalPath !== input.canonicalPath + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: existingLocation.canonicalPath, + requestedClaim: input.canonicalPath, }); - - const identityKey = stackIdentityKey( - input.identity.checkoutId, - input.identity.contextId, - input.stackName, - ); - const existingStackId = stackIdentities.get(identityKey); - if (existingStackId !== undefined) { - const stack = requireStack(existingStackId); - const activeToken = activeOperationByStack.get(stack.id); - const operation = activeToken === undefined ? undefined : operations.get(activeToken); - return { - outcome: "existing", - stack: copy(stack), - operation: operation === undefined ? undefined : copy(operation), - }; - } - - const baseStack: ManagedStackRecord = { - id: input.stackId, - projectId: input.identity.projectId, - checkoutId: input.identity.checkoutId, - contextId: input.identity.contextId, - name: input.stackName, - status: "pending", - lifecycle: "stopped", - runtimeRequest: input.configuration.runtimeRequest ?? "auto", - runtime: input.configuration.runtime, - paths: input.paths, - ports: [], - serviceVersions: {}, - runtimeMetadata: emptyRuntimeMetadata(), - createdAt: input.now, - updatedAt: input.now, - }; - const stack = applyConfiguration(baseStack, input.configuration, input.now); - transitionPortOwnership(undefined, stack); - stacks.set(stack.id, stack); - stackIdentities.set(identityKey, stack.id); - const claimed = claimOperation({ - token: input.operationToken, - stackId: stack.id, - kind: "start", - ownerPid: input.ownerPid, - now: input.now, + } + const pathOwner = [...locations.values()].find( + (location) => location.canonicalPath === input.canonicalPath, + ); + if (pathOwner !== undefined && pathOwner.checkoutId !== input.identity.checkoutId) { + throw new DuplicateManagedIdentityError({ + identityId: input.canonicalPath, + existingClaim: pathOwner.checkoutId, + requestedClaim: input.identity.checkoutId, }); - if (!claimed.acquired) { - throw new ManagedOperationOwnershipError({ stackId: stack.id }); - } - return { outcome: "create", stack: copy(stack), operation: claimed.operation }; + } + locations.set(existingLocation?.id ?? input.locationId, { + id: existingLocation?.id ?? input.locationId, + checkoutId: input.identity.checkoutId, + canonicalPath: input.canonicalPath, + lastSeenAt: input.now, }); - }, - publishPendingStack(stackId, operationToken, now) { - requireOwnedOperation(stackId, operationToken); - const current = requireStack(stackId); - const next: ManagedStackRecord = { - ...current, - status: "active", - updatedAt: now, - }; - stacks.set(stackId, next); - const operation = operations.get(operationToken); - if (operation !== undefined) { - operations.set(operationToken, { - ...operation, - status: "completed", - finishedAt: now, - }); + + const identityKey = stackIdentityKey( + input.identity.checkoutId, + input.identity.contextId, + input.stackName, + ); + const existingStackId = stackIdentities.get(identityKey); + if (existingStackId !== undefined) { + const stack = requireStack(existingStackId); + const activeToken = activeOperationByStack.get(stack.id); + const operation = activeToken === undefined ? undefined : operations.get(activeToken); + return { + outcome: "existing", + stack: copy(stack), + operation: operation === undefined ? undefined : copy(operation), + }; } - activeOperationByStack.delete(stackId); - return copy(next); - }, - abortPendingStack(stackId, operationToken) { - requireOwnedOperation(stackId, operationToken); - const stack = requireStack(stackId); - if (stack.status !== "pending") { - throw new ManagedOperationOwnershipError({ stackId }); + + const baseStack: ManagedStackRecord = { + id: input.stackId, + projectId: input.identity.projectId, + checkoutId: input.identity.checkoutId, + contextId: input.identity.contextId, + name: input.stackName, + status: "pending", + lifecycle: "stopped", + runtimeRequest: input.configuration.runtimeRequest ?? "auto", + runtime: input.configuration.runtime, + paths: input.paths, + ports: [], + serviceVersions: {}, + runtimeMetadata: emptyRuntimeMetadata(), + createdAt: input.now, + updatedAt: input.now, + }; + const stack = applyConfiguration(baseStack, input.configuration, input.now); + transitionPortOwnership(undefined, stack); + stacks.set(stack.id, stack); + stackIdentities.set(identityKey, stack.id); + const claimed = claimOperation({ + token: input.operationToken, + stackId: stack.id, + kind: "start", + ownerPid: input.ownerPid, + now: input.now, + }); + if (!claimed.acquired) { + throw new ManagedOperationOwnershipError({ stackId: stack.id }); } - discardPendingStack(stack, operationToken); - }, - getStack(stackId) { - const stack = stacks.get(stackId); - return stack === undefined ? undefined : copy(stack); - }, - listStacks(options) { - return [...stacks.values()] - .filter((stack) => options?.includeTombstoned === true || stack.status !== "tombstoned") - .sort( - (left, right) => - compareManagedText(left.createdAt, right.createdAt) || - compareManagedText(left.id, right.id), - ) - .map(copy); - }, - claimOperation, - finishOperation(stackId, operationToken, outcome, now, error) { - const operation = requireOwnedOperation(stackId, operationToken); + return { outcome: "create", stack: copy(stack), operation: claimed.operation }; + }); + }; + + const publishPendingStack = ( + stackId: string, + operationToken: string, + now: string, + ): ManagedStackRecord => { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { + ...current, + status: "active", + updatedAt: now, + }; + stacks.set(stackId, next); + const operation = operations.get(operationToken); + if (operation !== undefined) { operations.set(operationToken, { ...operation, - status: outcome, + status: "completed", finishedAt: now, - error, + }); + } + activeOperationByStack.delete(stackId); + return copy(next); + }; + + const abortPendingStack = (stackId: string, operationToken: string): void => { + requireOwnedOperation(stackId, operationToken); + const stack = requireStack(stackId); + if (stack.status !== "pending") { + throw new ManagedOperationOwnershipError({ stackId }); + } + discardPendingStack(stack, operationToken); + }; + + const finishOperation = ( + stackId: string, + operationToken: string, + outcome: "completed" | "failed", + now: string, + error?: string, + ): void => { + const operation = requireOwnedOperation(stackId, operationToken); + operations.set(operationToken, { + ...operation, + status: outcome, + finishedAt: now, + error, + }); + activeOperationByStack.delete(stackId); + }; + + const updateStack = (input: UpdateManagedStackInput): ManagedStackRecord => { + requireOwnedOperation(input.stackId, input.operationToken); + const current = requireStack(input.stackId); + assertManagedStackUpdatable(current); + const next = applyConfiguration(current, input, input.now); + transitionPortOwnership(current, next); + stacks.set(current.id, next); + return copy(next); + }; + + const reconcileOperation = ( + stackId: string, + operationToken: string, + lifecycle: ManagedStackRecord["lifecycle"], + now: string, + ): ReconcileManagedOperationResult => { + const operation = requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + if (current.status === "tombstoned") { + // A tombstoned row under a live claim is a deletion that died before + // releasing it. Registry state is already final, so recovery only + // releases the claim; reviving a lifecycle here would resurrect a + // deleted stack, and dropping the row would break idempotent deletion. + operations.set(operationToken, { + ...operation, + status: "failed", + finishedAt: now, + error: "Recovered after an abandoned deletion", }); activeOperationByStack.delete(stackId); - }, - updateStack(input) { - requireOwnedOperation(input.stackId, input.operationToken); - const current = requireStack(input.stackId); - assertManagedStackUpdatable(current); - const next = applyConfiguration(current, input, input.now); - transitionPortOwnership(current, next); - stacks.set(current.id, next); - return copy(next); - }, - listActiveOperations(startedBefore) { - return ( + return { outcome: "tombstoned", stack: copy(current) }; + } + if (current.status === "pending" && lifecycle === "stopped") { + discardPendingStack(current, operationToken); + return { outcome: "discarded" }; + } + const next: ManagedStackRecord = { + ...current, + status: current.status === "pending" ? "active" : current.status, + lifecycle, + updatedAt: now, + }; + transitionPortOwnership(current, next); + stacks.set(stackId, next); + operations.set(operationToken, { + ...operation, + status: "failed", + finishedAt: now, + error: `Recovered after runtime reconciliation (${lifecycle})`, + }); + activeOperationByStack.delete(stackId); + return { outcome: "recovered", stack: copy(next) }; + }; + + const tombstoneStack = ( + stackId: string, + operationToken: string, + now: string, + ): ManagedStackRecord => { + requireOwnedOperation(stackId, operationToken); + const current = requireStack(stackId); + const next: ManagedStackRecord = { + ...current, + status: "tombstoned", + lifecycle: "stopped", + ports: [], + runtimeMetadata: emptyRuntimeMetadata(), + updatedAt: now, + tombstonedAt: now, + }; + transitionPortOwnership(current, next); + stacks.set(stackId, next); + stackIdentities.delete(stackIdentityKey(current.checkoutId, current.contextId, current.name)); + return copy(next); + }; + + return { + prepareOrdinaryStack: (input) => + Effect.try({ + try: () => prepareOrdinaryStack(input), + catch: failsWith( + DuplicateManagedIdentityError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + }), + publishPendingStack: (stackId, operationToken, now) => + Effect.try({ + try: () => publishPendingStack(stackId, operationToken, now), + catch: failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + }), + abortPendingStack: (stackId, operationToken) => + Effect.try({ + try: () => abortPendingStack(stackId, operationToken), + catch: failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + }), + getStack: (stackId) => + Effect.sync(() => { + const stack = stacks.get(stackId); + return stack === undefined ? undefined : copy(stack); + }), + listStacks: (options) => + Effect.sync(() => + [...stacks.values()] + .filter((stack) => options?.includeTombstoned === true || stack.status !== "tombstoned") + .sort( + (left, right) => + compareManagedText(left.createdAt, right.createdAt) || + compareManagedText(left.id, right.id), + ) + .map(copy), + ), + claimOperation: (input) => + Effect.try({ + try: () => claimOperation(input), + catch: failsWith( + InvalidManagedOwnerPidError, + ManagedStackNotFoundError, + ), + }), + finishOperation: (stackId, operationToken, outcome, now, error) => + Effect.try({ + try: () => finishOperation(stackId, operationToken, outcome, now, error), + catch: failsWith(ManagedOperationOwnershipError), + }), + updateStack: (input) => + Effect.try({ + try: () => updateStack(input), + catch: failsWith( + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + ), + }), + listActiveOperations: (startedBefore) => + Effect.sync(() => [...activeOperationByStack.values()] .flatMap((token) => { const operation = operations.get(token); @@ -399,78 +553,41 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepository = compareManagedText(left.startedAt, right.startedAt) || compareManagedText(left.token, right.token), ) - .map(copy) - ); - }, - reconcileOperation(stackId, operationToken, lifecycle, now) { - const operation = requireOwnedOperation(stackId, operationToken); - const current = requireStack(stackId); - if (current.status === "tombstoned") { - // A tombstoned row under a live claim is a deletion that died before - // releasing it. Registry state is already final, so recovery only - // releases the claim; reviving a lifecycle here would resurrect a - // deleted stack, and dropping the row would break idempotent deletion. - operations.set(operationToken, { - ...operation, - status: "failed", - finishedAt: now, - error: "Recovered after an abandoned deletion", - }); - activeOperationByStack.delete(stackId); - return { outcome: "tombstoned", stack: copy(current) }; - } - if (current.status === "pending" && lifecycle === "stopped") { - discardPendingStack(current, operationToken); - return { outcome: "discarded" }; - } - const next: ManagedStackRecord = { - ...current, - status: current.status === "pending" ? "active" : current.status, - lifecycle, - updatedAt: now, - }; - transitionPortOwnership(current, next); - stacks.set(stackId, next); - operations.set(operationToken, { - ...operation, - status: "failed", - finishedAt: now, - error: `Recovered after runtime reconciliation (${lifecycle})`, - }); - activeOperationByStack.delete(stackId); - return { outcome: "recovered", stack: copy(next) }; - }, - tombstoneStack(stackId, operationToken, now) { - requireOwnedOperation(stackId, operationToken); - const current = requireStack(stackId); - const next: ManagedStackRecord = { - ...current, - status: "tombstoned", - lifecycle: "stopped", - ports: [], - runtimeMetadata: emptyRuntimeMetadata(), - updatedAt: now, - tombstonedAt: now, - }; - transitionPortOwnership(current, next); - stacks.set(stackId, next); - stackIdentities.delete(stackIdentityKey(current.checkoutId, current.contextId, current.name)); - return copy(next); - }, - listCheckoutLocations() { - return [...locations.values()] - .sort((left, right) => compareManagedText(left.canonicalPath, right.canonicalPath)) - .map(copy); - }, - pruneCheckoutLocations(locationIds) { - let removed = 0; - for (const id of new Set(locationIds)) { - if (locations.delete(id)) { - removed += 1; + .map(copy), + ), + reconcileOperation: (stackId, operationToken, lifecycle, now) => + Effect.try({ + try: () => reconcileOperation(stackId, operationToken, lifecycle, now), + catch: failsWith( + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + }), + tombstoneStack: (stackId, operationToken, now) => + Effect.try({ + try: () => tombstoneStack(stackId, operationToken, now), + catch: failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + }), + listCheckoutLocations: () => + Effect.sync(() => + [...locations.values()] + .sort((left, right) => compareManagedText(left.canonicalPath, right.canonicalPath)) + .map(copy), + ), + pruneCheckoutLocations: (locationIds) => + Effect.sync(() => { + let removed = 0; + for (const id of new Set(locationIds)) { + if (locations.delete(id)) { + removed += 1; + } } - } - return removed; - }, - close() {}, + return removed; + }), }; }; diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index 1284d54eb9..a8855504cd 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -1,3 +1,4 @@ +import { Context, type Effect } from "effect"; import { InvalidManagedOwnerPidError, InvalidManagedPortError, @@ -15,6 +16,7 @@ import { type ManagedStackRecord, type OrdinaryWorkspaceIdentity, } from "./model.ts"; +import type { DuplicateManagedIdentityError, ManagedOperationOwnershipError } from "./model.ts"; export interface PrepareOrdinaryStackInput { readonly identity: OrdinaryWorkspaceIdentity; @@ -72,34 +74,112 @@ export type ReconcileManagedOperationResult = | { readonly outcome: "discarded" } | { readonly outcome: "tombstoned"; readonly stack: ManagedStackRecord }; -export interface ManagedStackRepository { - prepareOrdinaryStack(input: PrepareOrdinaryStackInput): PrepareOrdinaryStackResult; - publishPendingStack(stackId: string, operationToken: string, now: string): ManagedStackRecord; - abortPendingStack(stackId: string, operationToken: string): void; - getStack(stackId: string): ManagedStackRecord | undefined; - listStacks(options?: { readonly includeTombstoned?: boolean }): ReadonlyArray; - claimOperation(input: ClaimManagedOperationInput): ClaimManagedOperationResult; - finishOperation( +/** Failures both adapters raise while registering an ordinary workspace stack. */ +export type PrepareOrdinaryStackFailure = + | DuplicateManagedIdentityError + | InvalidManagedOwnerPidError + | InvalidManagedPortError + | ManagedOperationOwnershipError + | ManagedPortReservationError + | ManagedStackNotFoundError; + +/** Failures both adapters raise while claiming an operation for a stack. */ +export type ClaimManagedOperationFailure = + | InvalidManagedOwnerPidError + | ManagedOperationOwnershipError + | ManagedStackNotFoundError; + +/** Failures both adapters raise while reconfiguring a published stack. */ +export type UpdateManagedStackFailure = + | InvalidManagedPortError + | ManagedOperationOwnershipError + | ManagedPendingStackUpdateError + | ManagedPortReservationError + | ManagedRunningStackPortChangeError + | ManagedStackNotFoundError; + +/** + * Failures both adapters raise while settling an abandoned operation. Adopting a + * stack re-reserves the ports it claims, so another stack holding one of them + * fails the reconciliation rather than stealing the lease. + */ +export type ReconcileManagedOperationFailure = + | InvalidManagedPortError + | ManagedOperationOwnershipError + | ManagedPortReservationError + | ManagedStackNotFoundError; + +/** Failures both adapters raise while resolving a stack under a live claim. */ +export type OwnedManagedStackFailure = ManagedOperationOwnershipError | ManagedStackNotFoundError; + +/** + * The registry contract shared by the persistent SQLite adapters and the + * in-memory test seam. + * + * Every method is an `Effect` whose error channel names the domain failures that + * decision can reach. Storage-level failures — a corrupt row, an unexpected + * driver error — are defects instead: they are not outcomes a caller can act on. + */ +export interface ManagedStackRepositoryShape { + readonly prepareOrdinaryStack: ( + input: PrepareOrdinaryStackInput, + ) => Effect.Effect; + readonly publishPendingStack: ( + stackId: string, + operationToken: string, + now: string, + ) => Effect.Effect; + readonly abortPendingStack: ( + stackId: string, + operationToken: string, + ) => Effect.Effect; + readonly getStack: (stackId: string) => Effect.Effect; + readonly listStacks: (options?: { + readonly includeTombstoned?: boolean; + }) => Effect.Effect>; + readonly claimOperation: ( + input: ClaimManagedOperationInput, + ) => Effect.Effect; + readonly finishOperation: ( stackId: string, operationToken: string, outcome: "completed" | "failed", now: string, error?: string, - ): void; - updateStack(input: UpdateManagedStackInput): ManagedStackRecord; - listActiveOperations(startedBefore?: string): ReadonlyArray; - reconcileOperation( + ) => Effect.Effect; + readonly updateStack: ( + input: UpdateManagedStackInput, + ) => Effect.Effect; + readonly listActiveOperations: ( + startedBefore?: string, + ) => Effect.Effect>; + readonly reconcileOperation: ( stackId: string, operationToken: string, lifecycle: ManagedStackLifecycle, now: string, - ): ReconcileManagedOperationResult; - tombstoneStack(stackId: string, operationToken: string, now: string): ManagedStackRecord; - listCheckoutLocations(): ReadonlyArray; - pruneCheckoutLocations(locationIds: ReadonlyArray): number; - close(): void; + ) => Effect.Effect; + readonly tombstoneStack: ( + stackId: string, + operationToken: string, + now: string, + ) => Effect.Effect; + readonly listCheckoutLocations: () => Effect.Effect>; + readonly pruneCheckoutLocations: (locationIds: ReadonlyArray) => Effect.Effect; } +/** + * The registry a managed stack service reads and writes. + * + * A persistent adapter owns a database handle, so it is provided as a scoped + * layer that closes the handle when the layer's scope closes; there is no + * `close` method on the contract for a caller to forget. + */ +export class ManagedStackRepository extends Context.Service< + ManagedStackRepository, + ManagedStackRepositoryShape +>()("stack/managed/ManagedStackRepository") {} + export const managedStackOccupiesPorts = (lifecycle: ManagedStackLifecycle): boolean => lifecycle === "running" || lifecycle === "starting" || lifecycle === "stopping"; diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index 6ff4511583..385e64944b 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -1,7 +1,19 @@ import { randomUUID } from "node:crypto"; -import { mkdir, rm } from "node:fs/promises"; +import { + Cause, + Context, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Schedule, +} from "effect"; import { DEFAULT_MANAGED_STACK_NAME, + InvalidManagedIdentityError, + InvalidManagedOwnerPidError, InvalidManagedStackNameError, ManagedAbandonedOperationError, ManagedOperationInProgressError, @@ -26,16 +38,24 @@ import { readOrdinaryWorkspaceIdentity, } from "./identity.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; -import { assertManagedStackRoot, managedStackPaths, resolveManagedStateRoot } from "./paths.ts"; +import { + assertManagedStackRoot, + managedStackPaths, + requireExplicitManagedStateRoot, +} from "./paths.ts"; import { errorCode } from "./error-code.ts"; +import { failsWith } from "./failure.ts"; import { assertManagedOwnerPid, isUsableManagedOwnerPid, - type ManagedStackRepository, + ManagedStackRepository, + type ClaimManagedOperationFailure, + type OwnedManagedStackFailure, + type PrepareOrdinaryStackFailure, + type UpdateManagedStackFailure, } from "./repository.ts"; export interface ManagedStackServiceOptions { - readonly repository: ManagedStackRepository; readonly stateRoot: string; readonly idFactory?: () => string; readonly clock?: () => Date; @@ -49,8 +69,14 @@ export interface ProvisionOrdinaryStackOptions { readonly workspacePath: string; readonly stackName?: string; readonly configuration?: ManagedStackConfiguration; - readonly initialize?: (stack: ManagedStackRecord) => Promise; - readonly validate?: (stack: ManagedStackRecord) => Promise; + /** + * Provisioning steps a caller owns. Their failures never reach the caller as + * themselves: whatever they fail with becomes the `cause` of a + * {@link ManagedStackInitializationError} once the pending stack is rolled + * back, so the error channel here is deliberately open. + */ + readonly initialize?: (stack: ManagedStackRecord) => Effect.Effect; + readonly validate?: (stack: ManagedStackRecord) => Effect.Effect; } export interface ProvisionOrdinaryStackResult { @@ -74,27 +100,28 @@ export interface DeleteManagedStackResult { | { readonly outcome: "retained"; readonly error: unknown }; } -interface ReconcileAbandonedOperationsBaseOptions { +interface ReconcileAbandonedOperationsBaseOptions { readonly inspectRuntime: ( stack: ManagedStackRecord, operation: ManagedOperationRecord, - ) => Promise<"running" | "stopped" | "unknown">; + ) => Effect.Effect<"running" | "stopped" | "unknown", E>; } -export type ReconcileAbandonedOperationsOptions = ReconcileAbandonedOperationsBaseOptions & - ( - | { - readonly startedBefore?: string; - readonly force?: never; - } - | { - readonly startedBefore?: never; - readonly force: { - readonly stackId: string; - readonly operationToken: string; - }; - } - ); +export type ReconcileAbandonedOperationsOptions = + ReconcileAbandonedOperationsBaseOptions & + ( + | { + readonly startedBefore?: string; + readonly force?: never; + } + | { + readonly startedBefore?: never; + readonly force: { + readonly stackId: string; + readonly operationToken: string; + }; + } + ); export interface RetainedManagedOperation { readonly operation: ManagedOperationRecord; @@ -134,30 +161,70 @@ export interface ReconcileAbandonedOperationsResult { readonly failures: ReadonlyArray; } -export interface ManagedStackService { +/** Claiming an operation on behalf of a caller, including a refused claim. */ +type RequireManagedOperationFailure = + | ClaimManagedOperationFailure + | InvalidManagedIdentityError + | ManagedOperationInProgressError; + +export type UpdateManagedStackConfigurationFailure = + | RequireManagedOperationFailure + | UpdateManagedStackFailure; + +export type ProvisionManagedStackFailure = + | InvalidManagedIdentityError + | InvalidManagedStackNameError + | ManagedAbandonedOperationError + | ManagedOperationInProgressError + | ManagedStackInitializationError + | ManagedStackNotFoundError + | ManagedStackPublicationTimeoutError + | PrepareOrdinaryStackFailure + | UpdateManagedStackConfigurationFailure; + +export type DeleteManagedStackFailure = + | ManagedStackNotFoundError + | ManagedStackNotStoppedError + | OwnedManagedStackFailure + | RequireManagedOperationFailure + | UpdateManagedStackFailure; + +export interface ManagedStackServiceShape { readonly stateRoot: string; - readonly repository: ManagedStackRepository; - provisionOrdinaryStack( + readonly provisionOrdinaryStack: ( options: ProvisionOrdinaryStackOptions, - ): Promise; - inspectOrdinaryWorkspace(workspacePath: string): Promise; - inspectStack(stackId: string): ManagedStackRecord | undefined; - listStacks(options?: { readonly includeTombstoned?: boolean }): ReadonlyArray; - updateStack( + ) => Effect.Effect; + readonly inspectOrdinaryWorkspace: ( + workspacePath: string, + ) => Effect.Effect; + readonly inspectStack: (stackId: string) => Effect.Effect; + readonly listStacks: (options?: { + readonly includeTombstoned?: boolean; + }) => Effect.Effect>; + readonly updateStack: ( stackId: string, configuration: ManagedStackConfiguration, - ): Promise; - deleteStack( + ) => Effect.Effect; + /** + * The `stop` callback's failure reaches the caller unchanged — a stack that + * refused to stop was not deleted — so its error type flows through. + */ + readonly deleteStack: ( stackId: string, - options?: { readonly stop?: (stack: ManagedStackRecord) => Promise }, - ): Promise; - reconcileAbandonedOperations( + options?: { readonly stop?: (stack: ManagedStackRecord) => Effect.Effect }, + ) => Effect.Effect; + /** + * Recovery reports rather than fails: a runtime it could not inspect is a + * retained operation, and a reclamation it could not finish is a reported + * failure. Only a forced target that is not a pair of managed UUIDs refuses + * the whole pass. + */ + readonly reconcileAbandonedOperations: ( options: ReconcileAbandonedOperationsOptions, - ): Promise; - pruneCheckoutLocations( - shouldPrune: (location: ManagedCheckoutLocation) => boolean | Promise, - ): Promise; - close(): void; + ) => Effect.Effect; + readonly pruneCheckoutLocations: ( + shouldPrune: (location: ManagedCheckoutLocation) => Effect.Effect, + ) => Effect.Effect; } const selectionForStack = (stack: ManagedStackRecord): ManagedStackSelection => ({ @@ -168,10 +235,36 @@ const selectionForStack = (stack: ManagedStackRecord): ManagedStackSelection => stackName: stack.name, }); -const wait = (milliseconds: number): Promise => - new Promise((resolve) => setTimeout(resolve, milliseconds)); +const provisionResult = ( + outcome: ProvisionOrdinaryStackResult["outcome"], + stack: ManagedStackRecord, + identityMarkerCreated: boolean, +): ProvisionOrdinaryStackResult => ({ + outcome, + selection: selectionForStack(stack), + stack, + identityMarkerCreated, +}); + +const deletionResult = ( + outcome: DeleteManagedStackResult["outcome"], + stack: ManagedStackRecord, + dataReclamation: DeleteManagedStackResult["dataReclamation"], +): DeleteManagedStackResult => ({ outcome, stack, dataReclamation }); + +const dataRemoved: DeleteManagedStackResult["dataReclamation"] = { outcome: "removed" }; + +const dataRetained = (error: unknown): DeleteManagedStackResult["dataReclamation"] => ({ + outcome: "retained", + error, +}); + +const unregisteredWorkspace: InspectOrdinaryWorkspaceResult = { registered: false, stacks: [] }; -/** Ceiling for {@link makeManagedStackService}'s publication poll backoff. */ +/** What one look at a stack awaiting publication can refuse to wait for. */ +type PublicationPollFailure = ManagedAbandonedOperationError | ManagedStackNotFoundError; + +/** Ceiling for the publication poll's backoff. */ const MAX_PUBLICATION_POLL_MS = 250; const stackNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; @@ -193,489 +286,646 @@ const processIsAlive = (pid: number): boolean => { } }; -export const makeManagedStackService = ( - options: ManagedStackServiceOptions, -): ManagedStackService => { - // Anchored and validated once, at the boundary, through the one resolver that - // owns state-root policy: a relative root injected here would be reinterpreted - // against the process' cwd at every later use, and a blank one would anchor - // every managed path to it. `stateRoot` is required in the option type, but a - // caller bypassing the type system (or a plain-JS caller) could still pass - // `undefined`, which would make `resolveManagedStateRoot` silently fall back - // to `SUPABASE_HOME`/the user's home directory instead of failing loudly. - if (options.stateRoot === undefined) { - throw new UnsafeManagedStackPathError({ - path: String(options.stateRoot), - reason: "Refusing to start a managed stack service without an explicit state root", - }); - } - const stateRoot = resolveManagedStateRoot({ stateRoot: options.stateRoot }); - const idFactory = options.idFactory ?? randomUUID; - const clock = options.clock ?? (() => new Date()); - // Validated here as well as in the repository: the pid is this service's own - // option, so the failure belongs to the caller that supplied it. - assertManagedOwnerPid(options.ownerPid); - const ownerPid = options.ownerPid ?? process.pid; - const publicationTimeoutMs = options.publicationTimeoutMs ?? 10_000; - const publicationPollMs = options.publicationPollMs ?? 10; - const isProcessAlive = options.isProcessAlive ?? processIsAlive; - const now = (): string => clock().toISOString(); - - const removeStackState = async (stack: ManagedStackRecord): Promise => { - const root = assertManagedStackRoot(stateRoot, stack.id, stack.paths.root); - await rm(root, { force: true, recursive: true }); - }; - - const reclaimStackState = async ( - stack: ManagedStackRecord, - ): Promise => { - try { - await removeStackState(stack); - return { outcome: "removed" }; - } catch (error: unknown) { - return { outcome: "retained", error }; - } - }; - - const finishOperationBestEffort = ( - stackId: string, - operationToken: string, - error: unknown, - ): boolean => { - try { - options.repository.finishOperation(stackId, operationToken, "failed", now(), String(error)); - return true; - } catch { - // Preserve the operation's original failure when ownership changed concurrently. - return false; - } - }; +/** + * The managed registry's policy layer: identity marker handling, provisioning + * order, publication waiting, deletion, and recovery of abandoned operations. + */ +export class ManagedStackService extends Context.Service< + ManagedStackService, + ManagedStackServiceShape +>()("stack/managed/ManagedStackService") { + static make( + options: ManagedStackServiceOptions, + ): Layer.Layer< + ManagedStackService, + InvalidManagedOwnerPidError | UnsafeManagedStackPathError, + FileSystem.FileSystem | ManagedStackRepository + > { + return Layer.effect( + this, + Effect.gen(function* () { + const repository = yield* ManagedStackRepository; + const fs = yield* FileSystem.FileSystem; + // Anchored and validated once, at the boundary, through the one resolver + // that owns state-root policy: a relative root injected here would be + // reinterpreted against the process' cwd at every later use, and a blank + // or missing one would anchor every managed path to it. + const stateRoot = yield* Effect.try({ + try: () => requireExplicitManagedStateRoot(options.stateRoot), + catch: failsWith(UnsafeManagedStackPathError), + }); + // Validated here as well as in the repository: the pid is this service's + // own option, so the failure belongs to the caller that supplied it. + yield* Effect.try({ + try: () => { + assertManagedOwnerPid(options.ownerPid); + }, + catch: failsWith(InvalidManagedOwnerPidError), + }); - /** - * A concurrent forced recovery can resolve this same operation before this - * call closes it out, but only after the delete's own data removal already - * ran — so the delete is provably done and its ownership race must not be - * reported as a failure. Any other error still propagates, since only that - * specific race is known to be harmless. - */ - const finishDeleteOperationTolerantly = (stackId: string, operationToken: string): void => { - try { - options.repository.finishOperation(stackId, operationToken, "completed", now()); - } catch (error: unknown) { - if (!(error instanceof ManagedOperationOwnershipError)) { - throw error; - } - } - }; - - const failRecoveryBestEffort = ( - stack: ManagedStackRecord | undefined, - operation: ManagedOperationRecord, - error: unknown, - ): boolean => { - if (stack === undefined || stack.status === "pending") { - return false; - } - try { - options.repository.updateStack({ - stackId: operation.stackId, - operationToken: operation.token, - lifecycle: "failed", - now: now(), - }); - } catch { - // Releasing the abandoned claim is still useful if the failed lifecycle cannot be recorded. - } - return finishOperationBestEffort(operation.stackId, operation.token, error); - }; - - const requireOperation = ( - stackId: string, - kind: ManagedOperationKind, - ): ManagedOperationRecord => { - const claimed = options.repository.claimOperation({ - token: createManagedUuid(idFactory, "operation token"), - stackId, - kind, - ownerPid, - now: now(), - }); - if (!claimed.acquired) { - throw new ManagedOperationInProgressError({ stackId, operation: claimed.operation }); - } - return claimed.operation; - }; - - // Publication normally lands within the first poll, so start tight and back - // off: a slow publisher must not be polled hundreds of times per second for - // the whole timeout window. The ceiling only ever slows polling down, so a - // caller asking for a slower interval than the ceiling keeps its own. - const backOffPublicationPoll = (pollMs: number): number => - Math.min(pollMs * 2, Math.max(MAX_PUBLICATION_POLL_MS, publicationPollMs)); - - const awaitPublication = async (pending: ManagedStackRecord): Promise => { - const deadline = performance.now() + publicationTimeoutMs; - let pollMs = publicationPollMs; - while (performance.now() <= deadline) { - const current = options.repository.getStack(pending.id); - if (current === undefined) { - throw new ManagedAbandonedOperationError({ stackId: pending.id }); - } - if (current.status === "active") { - return current; - } - if (current.status === "tombstoned") { - throw new ManagedStackNotFoundError({ stackId: current.id }); - } - // Never sleep past the deadline: the timeout is the caller's bound, not a - // floor a long poll interval may overshoot by a whole interval. - await wait(Math.max(Math.min(pollMs, deadline - performance.now()), 0)); - pollMs = backOffPublicationPoll(pollMs); - } - throw new ManagedStackPublicationTimeoutError({ stackId: pending.id }); - }; - - const updateStackRecord = async ( - stackId: string, - configuration: ManagedStackConfiguration, - ): Promise => { - const operation = requireOperation(stackId, "update"); - try { - const stack = options.repository.updateStack({ - stackId, - operationToken: operation.token, - now: now(), - ...configuration, - }); - options.repository.finishOperation(stackId, operation.token, "completed", now()); - return stack; - } catch (error: unknown) { - finishOperationBestEffort(stackId, operation.token, error); - throw error; - } - }; + const idFactory = options.idFactory ?? randomUUID; + const clock = options.clock ?? (() => new Date()); + const ownerPid = options.ownerPid ?? process.pid; + const publicationTimeoutMs = options.publicationTimeoutMs ?? 10_000; + const publicationPollMs = options.publicationPollMs ?? 10; + const isProcessAlive = options.isProcessAlive ?? processIsAlive; + const now = (): string => clock().toISOString(); - /** - * Reused stacks adopt the caller's requested configuration regardless of - * whether the record was already published or was awaited while another - * caller published it, so the outcome never depends on that timing. - */ - const applyRequestedConfiguration = async ( - stack: ManagedStackRecord, - configuration: ManagedStackConfiguration | undefined, - ): Promise => - configuration === undefined || Object.keys(configuration).length === 0 - ? stack - : updateStackRecord(stack.id, configuration); - - return { - stateRoot, - repository: options.repository, - async provisionOrdinaryStack(provisionOptions) { - const stackName = provisionOptions.stackName ?? DEFAULT_MANAGED_STACK_NAME; - if (!stackNamePattern.test(stackName)) { - throw new InvalidManagedStackNameError({ stackName }); - } - const canonicalPath = await canonicalizeOrdinaryWorkspacePath(provisionOptions.workspacePath); - const marker = await ensureOrdinaryWorkspaceIdentity(canonicalPath, idFactory); - const stackId = createManagedUuid(idFactory, "stackId"); - const prepared = options.repository.prepareOrdinaryStack({ - identity: marker.identity, - canonicalPath, - locationId: createManagedUuid(idFactory, "checkout location id"), - stackId, - stackName, - paths: managedStackPaths(stateRoot, stackId), - operationToken: createManagedUuid(idFactory, "operation token"), - ownerPid, - now: now(), - configuration: provisionOptions.configuration ?? {}, - }); - - if (prepared.outcome === "existing") { - if (prepared.stack.status === "active") { - if (prepared.operation !== undefined) { - throw new ManagedOperationInProgressError({ - stackId: prepared.stack.id, - operation: prepared.operation, - }); - } - const stack = await applyRequestedConfiguration( - prepared.stack, - provisionOptions.configuration, + const managedUuid = (label: string): Effect.Effect => + Effect.try({ + try: () => createManagedUuid(idFactory, label), + catch: failsWith(InvalidManagedIdentityError), + }); + + const requireManagedUuid = ( + value: string, + label: string, + ): Effect.Effect => + Effect.try({ + try: () => assertManagedUuid(value, label), + catch: failsWith(InvalidManagedIdentityError), + }); + + /** + * `isProcessAlive` is a caller-supplied seam that may answer + * synchronously or asynchronously, and may refuse to answer at all. + * Recovery reports a refusal as a retained operation, so the refusal is + * kept in the error channel here rather than being turned into a defect. + */ + const probeProcessAlive = (pid: number): Effect.Effect => + Effect.flatMap( + Effect.try({ try: () => isProcessAlive(pid), catch: (error: unknown) => error }), + (answer) => + typeof answer === "boolean" + ? Effect.succeed(answer) + : Effect.tryPromise({ try: () => answer, catch: (error: unknown) => error }), ); - return { - outcome: "reuse", - selection: selectionForStack(stack), - stack, - identityMarkerCreated: marker.created, - }; - } - if (prepared.operation === undefined) { - throw new ManagedAbandonedOperationError({ stackId: prepared.stack.id }); - } - // A stored pid that is not a usable pid means there is no owner to wait - // for, exactly as a missing one does: probing it could report a dead - // publisher as alive and make this caller wait out the whole - // publication timeout instead of reporting the abandoned claim. - if ( - !isUsableManagedOwnerPid(prepared.operation.ownerPid) || - !(await isProcessAlive(prepared.operation.ownerPid)) - ) { - throw new ManagedAbandonedOperationError({ stackId: prepared.stack.id }); - } - const published = await applyRequestedConfiguration( - await awaitPublication(prepared.stack), - provisionOptions.configuration, - ); - return { - outcome: "reuse", - selection: selectionForStack(published), - stack: published, - identityMarkerCreated: marker.created, - }; - } - - try { - await mkdir(prepared.stack.paths.data, { recursive: true, mode: 0o700 }); - await mkdir(prepared.stack.paths.logs, { recursive: true, mode: 0o700 }); - await mkdir(prepared.stack.paths.runtime, { recursive: true, mode: 0o700 }); - await provisionOptions.initialize?.(prepared.stack); - await provisionOptions.validate?.(prepared.stack); - const published = options.repository.publishPendingStack( - prepared.stack.id, - prepared.operation.token, - now(), - ); - return { - outcome: "create", - selection: selectionForStack(published), - stack: published, - identityMarkerCreated: marker.created, - }; - } catch (cause: unknown) { - const cleanupErrors: Array = []; - let aborted = false; - try { - options.repository.abortPendingStack(prepared.stack.id, prepared.operation.token); - aborted = true; - } catch (error: unknown) { - cleanupErrors.push(error); - } - if (aborted) { - try { - await removeStackState(prepared.stack); - } catch (error: unknown) { - cleanupErrors.push(error); + + /** + * A stack's directory is only ever removed through the path guard, so a + * forged or stale record cannot make recovery delete something outside the + * state root. Both refusals — the guard's and the filesystem's — are + * reported as retained data rather than propagated. + */ + const removeStackState = (stack: ManagedStackRecord) => + Effect.flatMap( + Effect.try({ + try: () => assertManagedStackRoot(stateRoot, stack.id, stack.paths.root), + catch: failsWith(UnsafeManagedStackPathError), + }), + (root) => fs.remove(root, { force: true, recursive: true }), + ); + + const reclaimStackState = ( + stack: ManagedStackRecord, + ): Effect.Effect => + removeStackState(stack).pipe( + Effect.as(dataRemoved), + Effect.catchCause((cause) => Effect.succeed(dataRetained(Cause.squash(cause)))), + ); + + const finishOperationBestEffort = ( + stackId: string, + operationToken: string, + error: unknown, + ): Effect.Effect => + repository.finishOperation(stackId, operationToken, "failed", now(), String(error)).pipe( + Effect.as(true), + // Preserve the operation's original failure when ownership changed concurrently. + Effect.catchCause(() => Effect.succeed(false)), + ); + + /** + * A concurrent forced recovery can resolve this same operation before + * this call closes it out, but only after the delete's own data removal + * already ran — so the delete is provably done and its ownership race + * must not be reported as a failure. Any other error still propagates, + * since only that specific race is known to be harmless. + */ + const finishDeleteOperationTolerantly = ( + stackId: string, + operationToken: string, + ): Effect.Effect => + repository + .finishOperation(stackId, operationToken, "completed", now()) + .pipe(Effect.catchTag("ManagedOperationOwnershipError", () => Effect.void)); + + const failRecoveryBestEffort = ( + stack: ManagedStackRecord | undefined, + operation: ManagedOperationRecord, + error: unknown, + ): Effect.Effect => { + if (stack === undefined || stack.status === "pending") { + return Effect.succeed(false); } - } - throw new ManagedStackInitializationError({ - stackId: prepared.stack.id, - cause, - cleanupErrors, - }); - } - }, - async inspectOrdinaryWorkspace(workspacePath) { - const canonicalPath = await canonicalizeOrdinaryWorkspacePath(workspacePath); - const identity = await readOrdinaryWorkspaceIdentity(canonicalPath); - if (identity === undefined) { - return { registered: false, stacks: [] }; - } - const stacks = options.repository - .listStacks() - .filter( - (stack) => - stack.projectId === identity.projectId && - stack.checkoutId === identity.checkoutId && - stack.contextId === identity.contextId, - ); - return { registered: stacks.length > 0, identity, stacks }; - }, - inspectStack(stackId) { - return options.repository.getStack(stackId); - }, - listStacks(listOptions) { - return options.repository.listStacks(listOptions); - }, - async updateStack(stackId, configuration) { - return updateStackRecord(stackId, configuration); - }, - async deleteStack(stackId, deleteOptions) { - const existing = options.repository.getStack(stackId); - if (existing === undefined) { - throw new ManagedStackNotFoundError({ stackId }); - } - if (existing.status === "tombstoned") { - return { - outcome: "no-op", - stack: existing, - dataReclamation: await reclaimStackState(existing), + return repository + .updateStack({ + stackId: operation.stackId, + operationToken: operation.token, + lifecycle: "failed", + now: now(), + }) + .pipe( + // Releasing the abandoned claim is still useful if the failed lifecycle cannot be recorded. + Effect.catchCause(() => Effect.void), + Effect.flatMap(() => + finishOperationBestEffort(operation.stackId, operation.token, error), + ), + ); }; - } - const operation = requireOperation(stackId, "delete"); - try { - const current = options.repository.getStack(stackId); - if (current === undefined) { - throw new ManagedStackNotFoundError({ stackId }); - } - if (current.status === "tombstoned") { - const dataReclamation = await reclaimStackState(current); - options.repository.finishOperation(stackId, operation.token, "completed", now()); - return { outcome: "no-op", stack: current, dataReclamation }; - } - if (current.lifecycle !== "stopped") { - if (deleteOptions?.stop === undefined) { - throw new ManagedStackNotStoppedError({ stackId }); - } - await deleteOptions.stop(current); - options.repository.updateStack({ - stackId, - operationToken: operation.token, - now: now(), - lifecycle: "stopped", - runtimeMetadata: { processIds: {}, containerIds: {} }, + + const requireOperation = ( + stackId: string, + kind: ManagedOperationKind, + ): Effect.Effect => + Effect.gen(function* () { + const token = yield* managedUuid("operation token"); + const claimed = yield* repository.claimOperation({ + token, + stackId, + kind, + ownerPid, + now: now(), + }); + if (!claimed.acquired) { + return yield* Effect.fail( + new ManagedOperationInProgressError({ stackId, operation: claimed.operation }), + ); + } + return claimed.operation; }); - } - const tombstoned = options.repository.tombstoneStack(stackId, operation.token, now()); - const dataReclamation = await reclaimStackState(tombstoned); - finishDeleteOperationTolerantly(stackId, operation.token); - return { outcome: "delete", stack: tombstoned, dataReclamation }; - } catch (error: unknown) { - finishOperationBestEffort(stackId, operation.token, error); - throw error; - } - }, - async reconcileAbandonedOperations(reconcileOptions) { - const recovered: Array = []; - const abortedStackIds: Array = []; - const reclaimedStackIds: Array = []; - const retained: Array = []; - const skippedOperationIds: Array = []; - const failures: Array = []; - const forcedOperation = reconcileOptions.force; - if (forcedOperation !== undefined) { - assertManagedUuid(forcedOperation.stackId, "forced recovery stackId"); - assertManagedUuid(forcedOperation.operationToken, "forced recovery operation token"); - } - const operations = options.repository - .listActiveOperations( - forcedOperation === undefined ? reconcileOptions.startedBefore : undefined, - ) - .filter( - (operation) => - forcedOperation === undefined || - (operation.stackId === forcedOperation.stackId && - operation.token === forcedOperation.operationToken), + + // Publication normally lands within the first poll, so start tight and + // back off: a slow publisher must not be polled hundreds of times per + // second for the whole timeout window. The ceiling only ever slows + // polling down, so a caller asking for a slower interval keeps its own. + const publicationPollCeiling = Math.max(MAX_PUBLICATION_POLL_MS, publicationPollMs); + const publicationPollSchedule = Schedule.exponential( + Duration.millis(publicationPollMs), + ).pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed( + Duration.millis(Math.min(Duration.toMillis(duration), publicationPollCeiling)), + ), + ), ); - for (const operation of operations) { - // A persisted pid that is not a usable pid is treated as no owner at - // all: asking the liveness probe about it could report a live owner and - // wedge this claim forever, which is the failure recovery exists to fix. - if (forcedOperation === undefined && isUsableManagedOwnerPid(operation.ownerPid)) { - try { - if (await isProcessAlive(operation.ownerPid)) { - retained.push({ operation, reason: "owner-alive" }); - continue; + + /** + * One look at a stack a caller is waiting for. `Option.none()` is the + * retryable answer — the row is still pending, so the poll schedules + * another look — while the two failures are final answers about a + * publisher that will never arrive. + */ + const pollPublication = ( + pending: ManagedStackRecord, + ): Effect.Effect, PublicationPollFailure> => + Effect.flatMap( + repository.getStack(pending.id), + (current): Effect.Effect, PublicationPollFailure> => { + if (current === undefined) { + return Effect.fail(new ManagedAbandonedOperationError({ stackId: pending.id })); + } + if (current.status === "active") { + return Effect.succeed(Option.some(current)); + } + if (current.status === "tombstoned") { + return Effect.fail(new ManagedStackNotFoundError({ stackId: current.id })); + } + return Effect.succeed(Option.none()); + }, + ); + + const awaitPublication = ( + pending: ManagedStackRecord, + ): Effect.Effect< + ManagedStackRecord, + | ManagedAbandonedOperationError + | ManagedStackNotFoundError + | ManagedStackPublicationTimeoutError + > => + pollPublication(pending).pipe( + Effect.repeat({ + schedule: publicationPollSchedule, + while: (published) => Option.isNone(published), + }), + // The timeout is the caller's bound on the whole wait, so it + // interrupts the poll rather than being checked between polls. + Effect.timeoutOrElse({ + duration: Duration.millis(publicationTimeoutMs), + orElse: () => + Effect.fail(new ManagedStackPublicationTimeoutError({ stackId: pending.id })), + }), + Effect.flatMap((published) => + Option.isSome(published) + ? Effect.succeed(published.value) + : Effect.fail(new ManagedStackPublicationTimeoutError({ stackId: pending.id })), + ), + ); + + const updateStackRecord = ( + stackId: string, + configuration: ManagedStackConfiguration, + ): Effect.Effect => + Effect.gen(function* () { + const operation = yield* requireOperation(stackId, "update"); + return yield* repository + .updateStack({ + stackId, + operationToken: operation.token, + now: now(), + ...configuration, + }) + .pipe( + Effect.tap(() => + repository.finishOperation(stackId, operation.token, "completed", now()), + ), + Effect.catchCause((cause) => + finishOperationBestEffort(stackId, operation.token, Cause.squash(cause)).pipe( + Effect.flatMap(() => Effect.failCause(cause)), + ), + ), + ); + }); + + /** + * Reused stacks adopt the caller's requested configuration regardless of + * whether the record was already published or was awaited while another + * caller published it, so the outcome never depends on that timing. + */ + const applyRequestedConfiguration = ( + stack: ManagedStackRecord, + configuration: ManagedStackConfiguration | undefined, + ): Effect.Effect => + configuration === undefined || Object.keys(configuration).length === 0 + ? Effect.succeed(stack) + : updateStackRecord(stack.id, configuration); + + const provisionOrdinaryStack = ( + provisionOptions: ProvisionOrdinaryStackOptions, + ): Effect.Effect => + Effect.gen(function* () { + const stackName = provisionOptions.stackName ?? DEFAULT_MANAGED_STACK_NAME; + if (!stackNamePattern.test(stackName)) { + return yield* Effect.fail(new InvalidManagedStackNameError({ stackName })); } - } catch (error: unknown) { - retained.push({ operation, reason: "owner-liveness-unknown", error }); - continue; - } - } - let stack: ManagedStackRecord | undefined; - try { - stack = options.repository.getStack(operation.stackId); - if (stack === undefined) { - skippedOperationIds.push(operation.token); - continue; - } - // A tombstoned row is a deletion that died before releasing its claim. - // Its registry state is already final, so `reconcileOperation` ignores - // the lifecycle for it — and tombstoning zeroed the runtime metadata an - // inspector would need, so asking could only answer "unknown" and leak - // the stack directory forever. - let lifecycle: ManagedStackLifecycle = "stopped"; - if (stack.status !== "tombstoned") { - let actual: "running" | "stopped" | "unknown"; - try { - actual = await reconcileOptions.inspectRuntime(stack, operation); - } catch (error: unknown) { - retained.push({ operation, reason: "runtime-inspection-failed", error }); - continue; + const canonicalPath = yield* canonicalizeOrdinaryWorkspacePath( + provisionOptions.workspacePath, + ); + const marker = yield* ensureOrdinaryWorkspaceIdentity(canonicalPath, idFactory); + const stackId = yield* managedUuid("stackId"); + const locationId = yield* managedUuid("checkout location id"); + const operationToken = yield* managedUuid("operation token"); + const paths = yield* Effect.try({ + try: () => managedStackPaths(stateRoot, stackId), + catch: failsWith(InvalidManagedIdentityError), + }); + const prepared = yield* repository.prepareOrdinaryStack({ + identity: marker.identity, + canonicalPath, + locationId, + stackId, + stackName, + paths, + operationToken, + ownerPid, + now: now(), + configuration: provisionOptions.configuration ?? {}, + }); + + if (prepared.outcome === "existing") { + if (prepared.stack.status === "active") { + if (prepared.operation !== undefined) { + return yield* Effect.fail( + new ManagedOperationInProgressError({ + stackId: prepared.stack.id, + operation: prepared.operation, + }), + ); + } + const stack = yield* applyRequestedConfiguration( + prepared.stack, + provisionOptions.configuration, + ); + return provisionResult("reuse", stack, marker.created); + } + if (prepared.operation === undefined) { + return yield* Effect.fail( + new ManagedAbandonedOperationError({ stackId: prepared.stack.id }), + ); + } + // A stored pid that is not a usable pid means there is no owner to + // wait for, exactly as a missing one does: probing it could report + // a dead publisher as alive and make this caller wait out the whole + // publication timeout instead of reporting the abandoned claim. + // Provisioning has no report to put a refused probe in, so a seam + // that cannot answer is a defect here rather than an outcome. + if ( + !isUsableManagedOwnerPid(prepared.operation.ownerPid) || + !(yield* Effect.orDie(probeProcessAlive(prepared.operation.ownerPid))) + ) { + return yield* Effect.fail( + new ManagedAbandonedOperationError({ stackId: prepared.stack.id }), + ); + } + const awaited = yield* awaitPublication(prepared.stack); + const published = yield* applyRequestedConfiguration( + awaited, + provisionOptions.configuration, + ); + return provisionResult("reuse", published, marker.created); } - if (actual === "unknown") { - retained.push({ operation, reason: "runtime-unknown" }); - continue; + + const pending = prepared.stack; + const operation = prepared.operation; + return yield* Effect.gen(function* () { + yield* fs.makeDirectory(pending.paths.data, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(pending.paths.logs, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(pending.paths.runtime, { recursive: true, mode: 0o700 }); + if (provisionOptions.initialize !== undefined) { + yield* provisionOptions.initialize(pending); + } + if (provisionOptions.validate !== undefined) { + yield* provisionOptions.validate(pending); + } + const published = yield* repository.publishPendingStack( + pending.id, + operation.token, + now(), + ); + return provisionResult("create", published, marker.created); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const cleanupErrors: Array = []; + const aborted = yield* Effect.exit( + repository.abortPendingStack(pending.id, operation.token), + ); + if (Exit.isFailure(aborted)) { + cleanupErrors.push(Cause.squash(aborted.cause)); + } else { + const reclaimed = yield* Effect.exit(removeStackState(pending)); + if (Exit.isFailure(reclaimed)) { + cleanupErrors.push(Cause.squash(reclaimed.cause)); + } + } + return yield* Effect.fail( + new ManagedStackInitializationError({ + stackId: pending.id, + cause: Cause.squash(cause), + cleanupErrors, + }), + ); + }), + ), + ); + }); + + const inspectOrdinaryWorkspace = ( + workspacePath: string, + ): Effect.Effect => + Effect.gen(function* () { + const canonicalPath = yield* canonicalizeOrdinaryWorkspacePath(workspacePath); + const identity = yield* readOrdinaryWorkspaceIdentity(canonicalPath); + if (identity === undefined) { + return unregisteredWorkspace; } - lifecycle = actual === "running" ? "running" : "stopped"; - } - const reconciled = options.repository.reconcileOperation( - stack.id, - operation.token, - lifecycle, - now(), - ); - if (reconciled.outcome === "recovered") { - recovered.push(reconciled.stack); - } else { - // Both remaining outcomes leave state on disk that no registry row - // will ever point at again: a discarded pending stack's partial - // provisioning, or the data a crashed deletion never got to remove. - // The stack is reported under either id list only once that data is - // actually gone; otherwise the removal failure is the whole report. - try { - await removeStackState(stack); - if (reconciled.outcome === "discarded") { - abortedStackIds.push(stack.id); - } else { - reclaimedStackIds.push(stack.id); + const stacks = (yield* repository.listStacks()).filter( + (stack) => + stack.projectId === identity.projectId && + stack.checkoutId === identity.checkoutId && + stack.contextId === identity.contextId, + ); + return { registered: stacks.length > 0, identity, stacks }; + }); + + const deleteStack = ( + stackId: string, + deleteOptions?: { + readonly stop?: (stack: ManagedStackRecord) => Effect.Effect; + }, + ): Effect.Effect => + Effect.gen(function* () { + const existing = yield* repository.getStack(stackId); + if (existing === undefined) { + return yield* Effect.fail(new ManagedStackNotFoundError({ stackId })); + } + if (existing.status === "tombstoned") { + return deletionResult("no-op", existing, yield* reclaimStackState(existing)); + } + const operation = yield* requireOperation(stackId, "delete"); + return yield* Effect.gen(function* () { + const current = yield* repository.getStack(stackId); + if (current === undefined) { + return yield* Effect.fail(new ManagedStackNotFoundError({ stackId })); + } + if (current.status === "tombstoned") { + const dataReclamation = yield* reclaimStackState(current); + yield* repository.finishOperation(stackId, operation.token, "completed", now()); + return deletionResult("no-op", current, dataReclamation); } - } catch (error: unknown) { - failures.push({ - operation, - phase: "state-reclamation", - operationReleased: true, - error, + if (current.lifecycle !== "stopped") { + const stop = deleteOptions?.stop; + if (stop === undefined) { + return yield* Effect.fail(new ManagedStackNotStoppedError({ stackId })); + } + yield* stop(current); + yield* repository.updateStack({ + stackId, + operationToken: operation.token, + now: now(), + lifecycle: "stopped", + runtimeMetadata: { processIds: {}, containerIds: {} }, + }); + } + const tombstoned = yield* repository.tombstoneStack(stackId, operation.token, now()); + const dataReclamation = yield* reclaimStackState(tombstoned); + yield* finishDeleteOperationTolerantly(stackId, operation.token); + return deletionResult("delete", tombstoned, dataReclamation); + }).pipe( + Effect.catchCause((cause) => + finishOperationBestEffort(stackId, operation.token, Cause.squash(cause)).pipe( + Effect.flatMap(() => Effect.failCause(cause)), + ), + ), + ); + }); + + const reconcileAbandonedOperations = ( + reconcileOptions: ReconcileAbandonedOperationsOptions, + ): Effect.Effect => + Effect.gen(function* () { + const recovered: Array = []; + const abortedStackIds: Array = []; + const reclaimedStackIds: Array = []; + const retained: Array = []; + const skippedOperationIds: Array = []; + const failures: Array = []; + const forcedOperation = reconcileOptions.force; + if (forcedOperation !== undefined) { + yield* requireManagedUuid(forcedOperation.stackId, "forced recovery stackId"); + yield* requireManagedUuid( + forcedOperation.operationToken, + "forced recovery operation token", + ); + } + const operations = (yield* repository.listActiveOperations( + forcedOperation === undefined ? reconcileOptions.startedBefore : undefined, + )).filter( + (operation) => + forcedOperation === undefined || + (operation.stackId === forcedOperation.stackId && + operation.token === forcedOperation.operationToken), + ); + + const settleOperation = (operation: ManagedOperationRecord): Effect.Effect => + Effect.gen(function* () { + // A persisted pid that is not a usable pid is treated as no owner + // at all: asking the liveness probe about it could report a live + // owner and wedge this claim forever, which is the failure + // recovery exists to fix. + if (forcedOperation === undefined && isUsableManagedOwnerPid(operation.ownerPid)) { + const alive = yield* Effect.exit(probeProcessAlive(operation.ownerPid)); + if (Exit.isFailure(alive)) { + retained.push({ + operation, + reason: "owner-liveness-unknown", + error: Cause.squash(alive.cause), + }); + return; + } + if (alive.value) { + retained.push({ operation, reason: "owner-alive" }); + return; + } + } + let claimedStack: ManagedStackRecord | undefined; + yield* Effect.gen(function* () { + const stack = yield* repository.getStack(operation.stackId); + claimedStack = stack; + if (stack === undefined) { + skippedOperationIds.push(operation.token); + return; + } + // A tombstoned row is a deletion that died before releasing its + // claim. Its registry state is already final, so + // `reconcileOperation` ignores the lifecycle for it — and + // tombstoning zeroed the runtime metadata an inspector would + // need, so asking could only answer "unknown" and leak the + // stack directory forever. + let lifecycle: ManagedStackLifecycle = "stopped"; + if (stack.status !== "tombstoned") { + const inspected = yield* Effect.exit( + reconcileOptions.inspectRuntime(stack, operation), + ); + if (Exit.isFailure(inspected)) { + retained.push({ + operation, + reason: "runtime-inspection-failed", + error: Cause.squash(inspected.cause), + }); + return; + } + if (inspected.value === "unknown") { + retained.push({ operation, reason: "runtime-unknown" }); + return; + } + lifecycle = inspected.value === "running" ? "running" : "stopped"; + } + const reconciled = yield* repository.reconcileOperation( + stack.id, + operation.token, + lifecycle, + now(), + ); + if (reconciled.outcome === "recovered") { + recovered.push(reconciled.stack); + return; + } + // Both remaining outcomes leave state on disk that no registry + // row will ever point at again: a discarded pending stack's + // partial provisioning, or the data a crashed deletion never + // got to remove. The stack is reported under either id list + // only once that data is actually gone; otherwise the removal + // failure is the whole report. + const removal = yield* Effect.exit(removeStackState(stack)); + if (Exit.isFailure(removal)) { + failures.push({ + operation, + phase: "state-reclamation", + operationReleased: true, + error: Cause.squash(removal.cause), + }); + return; + } + if (reconciled.outcome === "discarded") { + abortedStackIds.push(stack.id); + return; + } + reclaimedStackIds.push(stack.id); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const error = Cause.squash(cause); + if ( + error instanceof ManagedOperationOwnershipError || + error instanceof ManagedStackNotFoundError + ) { + skippedOperationIds.push(operation.token); + return; + } + failures.push({ + operation, + phase: "reconciliation", + operationReleased: yield* failRecoveryBestEffort( + claimedStack, + operation, + error, + ), + error, + }); + }), + ), + ); }); + + for (const operation of operations) { + yield* settleOperation(operation); } - } - } catch (error: unknown) { - if ( - error instanceof ManagedOperationOwnershipError || - error instanceof ManagedStackNotFoundError - ) { - skippedOperationIds.push(operation.token); - continue; - } - failures.push({ - operation, - phase: "reconciliation", - operationReleased: failRecoveryBestEffort(stack, operation, error), - error, + return { + recovered, + abortedStackIds, + reclaimedStackIds, + retained, + skippedOperationIds, + failures, + }; }); - } - } - return { - recovered, - abortedStackIds, - reclaimedStackIds, - retained, - skippedOperationIds, - failures, - }; - }, - async pruneCheckoutLocations(shouldPrune) { - const stale: Array = []; - for (const location of options.repository.listCheckoutLocations()) { - if (await shouldPrune(location)) { - stale.push(location.id); - } - } - return options.repository.pruneCheckoutLocations(stale); - }, - close() { - options.repository.close(); - }, - }; -}; + + const pruneCheckoutLocations = ( + shouldPrune: (location: ManagedCheckoutLocation) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const stale: Array = []; + for (const location of yield* repository.listCheckoutLocations()) { + if (yield* shouldPrune(location)) { + stale.push(location.id); + } + } + return yield* repository.pruneCheckoutLocations(stale); + }); + + return { + stateRoot, + provisionOrdinaryStack, + inspectOrdinaryWorkspace, + inspectStack: (stackId) => repository.getStack(stackId), + listStacks: (listOptions) => repository.listStacks(listOptions), + updateStack: updateStackRecord, + deleteStack, + reconcileAbandonedOperations, + pruneCheckoutLocations, + }; + }), + ); + } +} diff --git a/packages/stack/src/managed/sqlite-bun.ts b/packages/stack/src/managed/sqlite-bun.ts index db57769cc8..bf4780e04b 100644 --- a/packages/stack/src/managed/sqlite-bun.ts +++ b/packages/stack/src/managed/sqlite-bun.ts @@ -1,23 +1,17 @@ import { Database } from "bun:sqlite"; -import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; -import { dirname } from "node:path"; -import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; +import type { Layer } from "effect"; +import type { UnsupportedManagedRegistryVersionError } from "./model.ts"; +import type { ManagedStackRepository } from "./repository.ts"; +import { + hardenManagedRegistryFile, + sqliteManagedStackRepositoryLayer, + type ManagedSqliteDatabase, +} from "./sqlite.ts"; -export const openBunSqliteManagedStackRepository = (path: string) => { - if (path !== ":memory:") { - // The registry stores workspace paths, ports, and credential references - // that other local users must not read. Pre-create the database file with - // an owner-only mode so it never exists with umask-derived permissions, - // and retighten both it and a directory left looser by an earlier build. - // Doing this before the WAL conversion also makes the -wal/-shm sidecars - // inherit the owner-only mode. - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - chmodSync(dirname(path), 0o700); - closeSync(openSync(path, "a", 0o600)); - chmodSync(path, 0o600); - } +const openDatabase = (path: string): ManagedSqliteDatabase => { + hardenManagedRegistryFile(path); const database = new Database(path, { create: true }); - const adapter: ManagedSqliteDatabase = { + return { exec(sql) { database.exec(sql); }, @@ -39,10 +33,9 @@ export const openBunSqliteManagedStackRepository = (path: string) => { database.close(); }, }; - try { - return createSqliteManagedStackRepository(adapter); - } catch (error: unknown) { - database.close(); - throw error; - } }; + +export const bunSqliteManagedStackRepositoryLayer = ( + path: string, +): Layer.Layer => + sqliteManagedStackRepositoryLayer(() => openDatabase(path)); diff --git a/packages/stack/src/managed/sqlite-node.ts b/packages/stack/src/managed/sqlite-node.ts index 7a3b202815..1e5b265d9f 100644 --- a/packages/stack/src/managed/sqlite-node.ts +++ b/packages/stack/src/managed/sqlite-node.ts @@ -1,23 +1,17 @@ -import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; -import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { createSqliteManagedStackRepository, type ManagedSqliteDatabase } from "./sqlite.ts"; +import type { Layer } from "effect"; +import type { UnsupportedManagedRegistryVersionError } from "./model.ts"; +import type { ManagedStackRepository } from "./repository.ts"; +import { + hardenManagedRegistryFile, + sqliteManagedStackRepositoryLayer, + type ManagedSqliteDatabase, +} from "./sqlite.ts"; -export const openNodeSqliteManagedStackRepository = (path: string) => { - if (path !== ":memory:") { - // The registry stores workspace paths, ports, and credential references - // that other local users must not read. Pre-create the database file with - // an owner-only mode so it never exists with umask-derived permissions, - // and retighten both it and a directory left looser by an earlier build. - // Doing this before the WAL conversion also makes the -wal/-shm sidecars - // inherit the owner-only mode. - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - chmodSync(dirname(path), 0o700); - closeSync(openSync(path, "a", 0o600)); - chmodSync(path, 0o600); - } +const openDatabase = (path: string): ManagedSqliteDatabase => { + hardenManagedRegistryFile(path); const database = new DatabaseSync(path); - const adapter: ManagedSqliteDatabase = { + return { exec(sql) { database.exec(sql); }, @@ -39,10 +33,9 @@ export const openNodeSqliteManagedStackRepository = (path: string) => { database.close(); }, }; - try { - return createSqliteManagedStackRepository(adapter); - } catch (error: unknown) { - database.close(); - throw error; - } }; + +export const nodeSqliteManagedStackRepositoryLayer = ( + path: string, +): Layer.Layer => + sqliteManagedStackRepositoryLayer(() => openDatabase(path)); diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index fbc324f44d..b707be2550 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -1,9 +1,15 @@ -import { Schema } from "effect"; +import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; +import { dirname } from "node:path"; +import { Effect, Exit, Layer, Schema, Scope } from "effect"; import { DuplicateManagedIdentityError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, MANAGED_REGISTRY_SCHEMA_VERSION, ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, ManagedPortReservationError, + ManagedRunningStackPortChangeError, ManagedStackNotFoundError, UnsupportedManagedRegistryVersionError, type ManagedCheckoutLocation, @@ -21,22 +27,29 @@ import { type ManagedStackStatus, } from "./model.ts"; import type { + ClaimManagedOperationFailure, ClaimManagedOperationInput, ClaimManagedOperationResult, - ManagedStackRepository, + ManagedStackRepositoryShape, + OwnedManagedStackFailure, + PrepareOrdinaryStackFailure, PrepareOrdinaryStackInput, PrepareOrdinaryStackResult, + ReconcileManagedOperationFailure, ReconcileManagedOperationResult, + UpdateManagedStackFailure, UpdateManagedStackInput, } from "./repository.ts"; import { assertManagedOwnerPid, assertManagedStackUpdatable, managedStackOccupiesPorts, + ManagedStackRepository, reconcileManagedPortAssignments, validateManagedPortAssignments, } from "./repository.ts"; import { errorCode } from "./error-code.ts"; +import { failsWith, neverFails } from "./failure.ts"; type SqliteValue = null | number | string; @@ -302,29 +315,63 @@ const initializeSchema = (database: ManagedSqliteDatabase): void => { } }; -const transaction = (database: ManagedSqliteDatabase, run: () => A): A => { - database.exec("BEGIN IMMEDIATE"); +const commitPreservingCause = (database: ManagedSqliteDatabase): void => { try { - const result = run(); database.exec("COMMIT"); - return result; } catch (error: unknown) { rollbackPreservingCause(database); throw error; } }; -const readTransaction = (database: ManagedSqliteDatabase, run: () => A): A => { - database.exec("BEGIN"); - try { - const result = run(); - database.exec("COMMIT"); - return result; - } catch (error: unknown) { - rollbackPreservingCause(database); - throw error; - } -}; +/** + * Runs one registry decision inside a transaction. + * + * The decision itself stays a synchronous closure — the drivers are synchronous, + * and a partially applied decision must never be observable — while the + * transaction boundary is an acquired resource: the `Exit` decides whether the + * statement batch commits or rolls back, so an interrupted fiber cannot leave a + * transaction open. `catchFailure` names the domain failures the decision + * raises; anything else is a defect and still rolls back. + */ +const transaction = ( + database: ManagedSqliteDatabase, + run: () => A, + catchFailure: (error: unknown) => E, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + database.exec("BEGIN IMMEDIATE"); + }), + () => Effect.try({ try: run, catch: catchFailure }), + (_, exit) => + Effect.sync(() => { + if (Exit.isSuccess(exit)) { + commitPreservingCause(database); + return; + } + rollbackPreservingCause(database); + }), + ); + +const readTransaction = ( + database: ManagedSqliteDatabase, + run: () => A, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + database.exec("BEGIN"); + }), + () => Effect.try({ try: run, catch: neverFails }), + (_, exit) => + Effect.sync(() => { + if (Exit.isSuccess(exit)) { + commitPreservingCause(database); + return; + } + rollbackPreservingCause(database); + }), + ); const decodePort = (row: unknown): ManagedPortAssignment => ({ key: getString(row, "key"), @@ -498,26 +545,23 @@ const claimOperation = ( database: ManagedSqliteDatabase, input: ClaimManagedOperationInput, ): ClaimManagedOperationResult => { - assertManagedOwnerPid(input.ownerPid); - return transaction(database, () => { - requireStack(database, input.stackId); - const active = getActiveOperation(database, input.stackId); - if (active !== undefined) { - return { acquired: false, operation: active }; - } - database - .prepare( - `INSERT INTO operations + requireStack(database, input.stackId); + const active = getActiveOperation(database, input.stackId); + if (active !== undefined) { + return { acquired: false, operation: active }; + } + database + .prepare( + `INSERT INTO operations (token, stack_id, kind, status, owner_pid, started_at) VALUES (?, ?, ?, 'active', ?, ?)`, - ) - .run([input.token, input.stackId, input.kind, input.ownerPid ?? null, input.now]); - const operation = getActiveOperation(database, input.stackId); - if (operation === undefined) { - throw new ManagedOperationOwnershipError({ stackId: input.stackId }); - } - return { acquired: true, operation }; - }); + ) + .run([input.token, input.stackId, input.kind, input.ownerPid ?? null, input.now]); + const operation = getActiveOperation(database, input.stackId); + if (operation === undefined) { + throw new ManagedOperationOwnershipError({ stackId: input.stackId }); + } + return { acquired: true, operation }; }; const insertConfiguration = ( @@ -565,331 +609,506 @@ const insertConfiguration = ( ); }; -export const createSqliteManagedStackRepository = ( +const prepareOrdinaryStack = ( database: ManagedSqliteDatabase, -): ManagedStackRepository => { - initializeSchema(database); + input: PrepareOrdinaryStackInput, +): PrepareOrdinaryStackResult => { + database + .prepare("INSERT OR IGNORE INTO projects (id, created_at) VALUES (?, ?)") + .run([input.identity.projectId, input.now]); - return { - prepareOrdinaryStack(input): PrepareOrdinaryStackResult { - assertManagedOwnerPid(input.ownerPid); - return transaction(database, () => { - database - .prepare("INSERT OR IGNORE INTO projects (id, created_at) VALUES (?, ?)") - .run([input.identity.projectId, input.now]); - - const checkoutRow = database - .prepare("SELECT project_id FROM checkouts WHERE id = ?") - .get([input.identity.checkoutId]); - if ( - checkoutRow !== undefined && - getString(checkoutRow, "project_id") !== input.identity.projectId - ) { - throw new DuplicateManagedIdentityError({ - identityId: input.identity.checkoutId, - existingClaim: getString(checkoutRow, "project_id"), - requestedClaim: input.identity.projectId, - }); - } - database - .prepare("INSERT OR IGNORE INTO checkouts (id, project_id, created_at) VALUES (?, ?, ?)") - .run([input.identity.checkoutId, input.identity.projectId, input.now]); - - const contextRow = database - .prepare("SELECT checkout_id FROM contexts WHERE id = ?") - .get([input.identity.contextId]); - if ( - contextRow !== undefined && - getString(contextRow, "checkout_id") !== input.identity.checkoutId - ) { - throw new DuplicateManagedIdentityError({ - identityId: input.identity.contextId, - existingClaim: getString(contextRow, "checkout_id"), - requestedClaim: input.identity.checkoutId, - }); - } - database - .prepare(`INSERT OR IGNORE INTO contexts (id, checkout_id, created_at) VALUES (?, ?, ?)`) - .run([input.identity.contextId, input.identity.checkoutId, input.now]); - - const checkoutLocation = database - .prepare("SELECT * FROM checkout_locations WHERE checkout_id = ?") - .get([input.identity.checkoutId]); - if ( - checkoutLocation !== undefined && - getString(checkoutLocation, "canonical_path") !== input.canonicalPath - ) { - throw new DuplicateManagedIdentityError({ - identityId: input.identity.checkoutId, - existingClaim: getString(checkoutLocation, "canonical_path"), - requestedClaim: input.canonicalPath, - }); - } - const pathLocation = database - .prepare("SELECT * FROM checkout_locations WHERE canonical_path = ?") - .get([input.canonicalPath]); - if ( - pathLocation !== undefined && - getString(pathLocation, "checkout_id") !== input.identity.checkoutId - ) { - throw new DuplicateManagedIdentityError({ - identityId: input.canonicalPath, - existingClaim: getString(pathLocation, "checkout_id"), - requestedClaim: input.identity.checkoutId, - }); - } - if (checkoutLocation === undefined) { - database - .prepare( - `INSERT INTO checkout_locations + const checkoutRow = database + .prepare("SELECT project_id FROM checkouts WHERE id = ?") + .get([input.identity.checkoutId]); + if ( + checkoutRow !== undefined && + getString(checkoutRow, "project_id") !== input.identity.projectId + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: getString(checkoutRow, "project_id"), + requestedClaim: input.identity.projectId, + }); + } + database + .prepare("INSERT OR IGNORE INTO checkouts (id, project_id, created_at) VALUES (?, ?, ?)") + .run([input.identity.checkoutId, input.identity.projectId, input.now]); + + const contextRow = database + .prepare("SELECT checkout_id FROM contexts WHERE id = ?") + .get([input.identity.contextId]); + if ( + contextRow !== undefined && + getString(contextRow, "checkout_id") !== input.identity.checkoutId + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.contextId, + existingClaim: getString(contextRow, "checkout_id"), + requestedClaim: input.identity.checkoutId, + }); + } + database + .prepare(`INSERT OR IGNORE INTO contexts (id, checkout_id, created_at) VALUES (?, ?, ?)`) + .run([input.identity.contextId, input.identity.checkoutId, input.now]); + + const checkoutLocation = database + .prepare("SELECT * FROM checkout_locations WHERE checkout_id = ?") + .get([input.identity.checkoutId]); + if ( + checkoutLocation !== undefined && + getString(checkoutLocation, "canonical_path") !== input.canonicalPath + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.identity.checkoutId, + existingClaim: getString(checkoutLocation, "canonical_path"), + requestedClaim: input.canonicalPath, + }); + } + const pathLocation = database + .prepare("SELECT * FROM checkout_locations WHERE canonical_path = ?") + .get([input.canonicalPath]); + if ( + pathLocation !== undefined && + getString(pathLocation, "checkout_id") !== input.identity.checkoutId + ) { + throw new DuplicateManagedIdentityError({ + identityId: input.canonicalPath, + existingClaim: getString(pathLocation, "checkout_id"), + requestedClaim: input.identity.checkoutId, + }); + } + if (checkoutLocation === undefined) { + database + .prepare( + `INSERT INTO checkout_locations (id, checkout_id, canonical_path, last_seen_at) VALUES (?, ?, ?, ?)`, - ) - .run([input.locationId, input.identity.checkoutId, input.canonicalPath, input.now]); - } else { - database - .prepare("UPDATE checkout_locations SET last_seen_at = ? WHERE id = ?") - .run([input.now, getString(checkoutLocation, "id")]); - } + ) + .run([input.locationId, input.identity.checkoutId, input.canonicalPath, input.now]); + } else { + database + .prepare("UPDATE checkout_locations SET last_seen_at = ? WHERE id = ?") + .run([input.now, getString(checkoutLocation, "id")]); + } - const existingRow = database - .prepare( - `SELECT * FROM stacks + const existingRow = database + .prepare( + `SELECT * FROM stacks WHERE checkout_id = ? AND context_id = ? AND name = ? AND status != 'tombstoned'`, - ) - .get([input.identity.checkoutId, input.identity.contextId, input.stackName]); - if (existingRow !== undefined) { - const stack = decodeStack(database, existingRow); - const operation = getActiveOperation(database, stack.id); - return { outcome: "existing", stack, operation }; - } + ) + .get([input.identity.checkoutId, input.identity.contextId, input.stackName]); + if (existingRow !== undefined) { + const stack = decodeStack(database, existingRow); + const operation = getActiveOperation(database, stack.id); + return { outcome: "existing", stack, operation }; + } - insertConfiguration(database, input); - database - .prepare( - `INSERT INTO operations + insertConfiguration(database, input); + database + .prepare( + `INSERT INTO operations (token, stack_id, kind, status, owner_pid, started_at) VALUES (?, ?, 'start', 'active', ?, ?)`, - ) - .run([input.operationToken, input.stackId, input.ownerPid ?? null, input.now]); - const stack = requireStack(database, input.stackId); - const operation = getActiveOperation(database, input.stackId); - if (operation === undefined) { - throw new ManagedOperationOwnershipError({ stackId: input.stackId }); - } - return { outcome: "create", stack, operation }; - }); - }, - publishPendingStack(stackId, operationToken, now) { - return transaction(database, () => { - requireOwnedOperation(database, stackId, operationToken); - database - .prepare("UPDATE stacks SET status = 'active', updated_at = ? WHERE id = ?") - .run([now, stackId]); - database - .prepare( - `UPDATE operations + ) + .run([input.operationToken, input.stackId, input.ownerPid ?? null, input.now]); + const stack = requireStack(database, input.stackId); + const operation = getActiveOperation(database, input.stackId); + if (operation === undefined) { + throw new ManagedOperationOwnershipError({ stackId: input.stackId }); + } + return { outcome: "create", stack, operation }; +}; + +const publishPendingStack = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + now: string, +): ManagedStackRecord => { + requireOwnedOperation(database, stackId, operationToken); + database + .prepare("UPDATE stacks SET status = 'active', updated_at = ? WHERE id = ?") + .run([now, stackId]); + database + .prepare( + `UPDATE operations SET status = 'completed', finished_at = ? WHERE token = ? AND stack_id = ?`, - ) - .run([now, operationToken, stackId]); - return requireStack(database, stackId); - }); - }, - abortPendingStack(stackId, operationToken) { - transaction(database, () => { - requireOwnedOperation(database, stackId, operationToken); - const stack = requireStack(database, stackId); - if (stack.status !== "pending") { - throw new ManagedOperationOwnershipError({ stackId }); - } - database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); - }); - }, - getStack(stackId) { - return readTransaction(database, () => getStack(database, stackId)); - }, - listStacks(options) { - return readTransaction(database, () => { - const rows = - options?.includeTombstoned === true - ? database.prepare("SELECT * FROM stacks ORDER BY created_at, id").all() - : database - .prepare( - "SELECT * FROM stacks WHERE status != 'tombstoned' ORDER BY created_at, id", - ) - .all(); - const portsByStack = queryPortsByStack( - database, - rows.map((row) => getString(row, "id")), - ); - return rows.map((row) => - decodeStackWithPorts(row, portsByStack.get(getString(row, "id")) ?? []), - ); - }); - }, - claimOperation(input) { - return claimOperation(database, input); - }, - finishOperation(stackId, operationToken, outcome, now, error) { - transaction(database, () => { - requireOwnedOperation(database, stackId, operationToken); - database - .prepare( - `UPDATE operations + ) + .run([now, operationToken, stackId]); + return requireStack(database, stackId); +}; + +const abortPendingStack = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, +): void => { + requireOwnedOperation(database, stackId, operationToken); + const stack = requireStack(database, stackId); + if (stack.status !== "pending") { + throw new ManagedOperationOwnershipError({ stackId }); + } + database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); +}; + +const selectStacks = ( + database: ManagedSqliteDatabase, + options?: { readonly includeTombstoned?: boolean }, +): ReadonlyArray => { + const rows = + options?.includeTombstoned === true + ? database.prepare("SELECT * FROM stacks ORDER BY created_at, id").all() + : database + .prepare("SELECT * FROM stacks WHERE status != 'tombstoned' ORDER BY created_at, id") + .all(); + const portsByStack = queryPortsByStack( + database, + rows.map((row) => getString(row, "id")), + ); + return rows.map((row) => decodeStackWithPorts(row, portsByStack.get(getString(row, "id")) ?? [])); +}; + +const finishOperation = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + outcome: "completed" | "failed", + now: string, + error?: string, +): void => { + requireOwnedOperation(database, stackId, operationToken); + database + .prepare( + `UPDATE operations SET status = ?, finished_at = ?, error = ? WHERE token = ? AND stack_id = ?`, - ) - .run([outcome, now, error ?? null, operationToken, stackId]); - }); - }, - updateStack(input: UpdateManagedStackInput) { - return transaction(database, () => { - requireOwnedOperation(database, input.stackId, input.operationToken); - const current = requireStack(database, input.stackId); - assertManagedStackUpdatable(current); - const runtimeRequest = input.runtimeRequest ?? current.runtimeRequest; - const runtime = input.runtime ?? current.runtime; - const lifecycle = input.lifecycle ?? current.lifecycle; - const serviceVersions = input.serviceVersions ?? current.serviceVersions; - const runtimeMetadata = input.runtimeMetadata ?? current.runtimeMetadata; - const configFingerprint = input.configFingerprint ?? current.configFingerprint; - const credentialsReference = input.credentialsReference ?? current.credentialsReference; - const ports = reconcileManagedPortAssignments(current, input.ports, lifecycle); - database - .prepare( - `UPDATE stacks SET + ) + .run([outcome, now, error ?? null, operationToken, stackId]); +}; + +const updateStack = ( + database: ManagedSqliteDatabase, + input: UpdateManagedStackInput, +): ManagedStackRecord => { + requireOwnedOperation(database, input.stackId, input.operationToken); + const current = requireStack(database, input.stackId); + assertManagedStackUpdatable(current); + const runtimeRequest = input.runtimeRequest ?? current.runtimeRequest; + const runtime = input.runtime ?? current.runtime; + const lifecycle = input.lifecycle ?? current.lifecycle; + const serviceVersions = input.serviceVersions ?? current.serviceVersions; + const runtimeMetadata = input.runtimeMetadata ?? current.runtimeMetadata; + const configFingerprint = input.configFingerprint ?? current.configFingerprint; + const credentialsReference = input.credentialsReference ?? current.credentialsReference; + const ports = reconcileManagedPortAssignments(current, input.ports, lifecycle); + database + .prepare( + `UPDATE stacks SET lifecycle = ?, runtime_request = ?, runtime = ?, service_versions_json = ?, runtime_metadata_json = ?, config_fingerprint = ?, credentials_reference = ?, updated_at = ? WHERE id = ?`, - ) - .run([ - lifecycle, - runtimeRequest, - runtime ?? null, - JSON.stringify(serviceVersions), - JSON.stringify(runtimeMetadata), - configFingerprint ?? null, - credentialsReference ?? null, - input.now, - input.stackId, - ]); - replacePorts(database, input.stackId, ports, lifecycle); - return requireStack(database, input.stackId); - }); - }, - listActiveOperations(startedBefore) { - // The token tie-break keeps claims that share one `startedAt` in a - // defined order instead of whatever order the sorter happens to emit. - const rows = - startedBefore === undefined - ? database - .prepare( - "SELECT * FROM operations WHERE status = 'active' ORDER BY started_at, token", - ) - .all() - : database - .prepare( - `SELECT * FROM operations + ) + .run([ + lifecycle, + runtimeRequest, + runtime ?? null, + JSON.stringify(serviceVersions), + JSON.stringify(runtimeMetadata), + configFingerprint ?? null, + credentialsReference ?? null, + input.now, + input.stackId, + ]); + replacePorts(database, input.stackId, ports, lifecycle); + return requireStack(database, input.stackId); +}; + +const selectActiveOperations = ( + database: ManagedSqliteDatabase, + startedBefore?: string, +): ReadonlyArray => { + // The token tie-break keeps claims that share one `startedAt` in a + // defined order instead of whatever order the sorter happens to emit. + const rows = + startedBefore === undefined + ? database + .prepare("SELECT * FROM operations WHERE status = 'active' ORDER BY started_at, token") + .all() + : database + .prepare( + `SELECT * FROM operations WHERE status = 'active' AND started_at < ? ORDER BY started_at, token`, - ) - .all([startedBefore]); - return rows.map(decodeOperation); - }, - reconcileOperation(stackId, operationToken, lifecycle, now): ReconcileManagedOperationResult { - return transaction(database, () => { - requireOwnedOperation(database, stackId, operationToken); - const current = requireStack(database, stackId); - if (current.status === "tombstoned") { - // A tombstoned row under a live claim is a deletion that died before - // releasing it. Registry state is already final, so recovery only - // releases the claim; reviving a lifecycle here would resurrect a - // deleted stack, and dropping the row would break idempotent deletion. - database - .prepare( - `UPDATE operations SET + ) + .all([startedBefore]); + return rows.map(decodeOperation); +}; + +const reconcileOperation = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + lifecycle: ManagedStackLifecycle, + now: string, +): ReconcileManagedOperationResult => { + requireOwnedOperation(database, stackId, operationToken); + const current = requireStack(database, stackId); + if (current.status === "tombstoned") { + // A tombstoned row under a live claim is a deletion that died before + // releasing it. Registry state is already final, so recovery only + // releases the claim; reviving a lifecycle here would resurrect a + // deleted stack, and dropping the row would break idempotent deletion. + database + .prepare( + `UPDATE operations SET status = 'failed', finished_at = ?, error = ? WHERE token = ? AND stack_id = ?`, - ) - .run([now, "Recovered after an abandoned deletion", operationToken, stackId]); - return { outcome: "tombstoned", stack: current }; - } - if (current.status === "pending" && lifecycle === "stopped") { - database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); - return { outcome: "discarded" }; - } - replacePorts(database, stackId, current.ports, lifecycle); - database - .prepare( - `UPDATE stacks SET + ) + .run([now, "Recovered after an abandoned deletion", operationToken, stackId]); + return { outcome: "tombstoned", stack: current }; + } + if (current.status === "pending" && lifecycle === "stopped") { + database.prepare("DELETE FROM stacks WHERE id = ?").run([stackId]); + return { outcome: "discarded" }; + } + replacePorts(database, stackId, current.ports, lifecycle); + database + .prepare( + `UPDATE stacks SET status = CASE WHEN status = 'pending' THEN 'active' ELSE status END, lifecycle = ?, updated_at = ? WHERE id = ?`, - ) - .run([lifecycle, now, stackId]); - database - .prepare( - `UPDATE operations SET + ) + .run([lifecycle, now, stackId]); + database + .prepare( + `UPDATE operations SET status = 'failed', finished_at = ?, error = ? WHERE token = ? AND stack_id = ?`, - ) - .run([ - now, - `Recovered after runtime reconciliation (${lifecycle})`, - operationToken, - stackId, - ]); - return { outcome: "recovered", stack: requireStack(database, stackId) }; - }); - }, - tombstoneStack(stackId, operationToken, now) { - return transaction(database, () => { - requireOwnedOperation(database, stackId, operationToken); - requireStack(database, stackId); - database.prepare("DELETE FROM ports WHERE stack_id = ?").run([stackId]); - database - .prepare( - `UPDATE stacks SET + ) + .run([now, `Recovered after runtime reconciliation (${lifecycle})`, operationToken, stackId]); + return { outcome: "recovered", stack: requireStack(database, stackId) }; +}; + +const tombstoneStack = ( + database: ManagedSqliteDatabase, + stackId: string, + operationToken: string, + now: string, +): ManagedStackRecord => { + requireOwnedOperation(database, stackId, operationToken); + requireStack(database, stackId); + database.prepare("DELETE FROM ports WHERE stack_id = ?").run([stackId]); + database + .prepare( + `UPDATE stacks SET status = 'tombstoned', lifecycle = 'stopped', runtime_metadata_json = ?, updated_at = ?, tombstoned_at = ? WHERE id = ?`, - ) - .run([JSON.stringify({ processIds: {}, containerIds: {} }), now, now, stackId]); - return requireStack(database, stackId); - }); - }, - listCheckoutLocations() { - return database - .prepare("SELECT * FROM checkout_locations ORDER BY canonical_path") - .all() - .map( - (row): ManagedCheckoutLocation => ({ - id: getString(row, "id"), - checkoutId: getString(row, "checkout_id"), - canonicalPath: getString(row, "canonical_path"), - lastSeenAt: getString(row, "last_seen_at"), - }), - ); - }, - pruneCheckoutLocations(locationIds) { - return transaction(database, () => { - let removed = 0; - const statement = database.prepare("DELETE FROM checkout_locations WHERE id = ?"); - for (const id of new Set(locationIds)) { - const existing = database - .prepare("SELECT id FROM checkout_locations WHERE id = ?") - .get([id]); - if (existing !== undefined) { - statement.run([id]); - removed += 1; - } - } - return removed; - }); - }, - close() { - database.close(); + ) + .run([JSON.stringify({ processIds: {}, containerIds: {} }), now, now, stackId]); + return requireStack(database, stackId); +}; + +const selectCheckoutLocations = ( + database: ManagedSqliteDatabase, +): ReadonlyArray => + database + .prepare("SELECT * FROM checkout_locations ORDER BY canonical_path") + .all() + .map( + (row): ManagedCheckoutLocation => ({ + id: getString(row, "id"), + checkoutId: getString(row, "checkout_id"), + canonicalPath: getString(row, "canonical_path"), + lastSeenAt: getString(row, "last_seen_at"), + }), + ); + +const pruneCheckoutLocations = ( + database: ManagedSqliteDatabase, + locationIds: ReadonlyArray, +): number => { + let removed = 0; + const statement = database.prepare("DELETE FROM checkout_locations WHERE id = ?"); + for (const id of new Set(locationIds)) { + const existing = database.prepare("SELECT id FROM checkout_locations WHERE id = ?").get([id]); + if (existing !== undefined) { + statement.run([id]); + removed += 1; + } + } + return removed; +}; + +/** + * The owner pid is validated before the transaction opens: it is the caller's + * own input, not a decision about persisted state, and a value recovery could + * never probe must not even begin a write. + */ +const requireOwnerPid = ( + ownerPid: number | undefined, +): Effect.Effect => + Effect.try({ + try: () => { + assertManagedOwnerPid(ownerPid); }, - }; + catch: failsWith(InvalidManagedOwnerPidError), + }); + +/** + * Binds the registry contract to an open SQLite handle. + * + * The schema is initialized as part of building the repository, so a registry + * written by an unsupported version fails here rather than at the first query. + * Closing the handle belongs to the layer that opened it — see + * {@link sqliteManagedStackRepositoryLayer} — so the contract has no `close` + * method for a caller to forget. + */ +const createSqliteManagedStackRepository = ( + database: ManagedSqliteDatabase, +): Effect.Effect => + Effect.gen(function* () { + yield* Effect.try({ + try: () => { + initializeSchema(database); + }, + catch: failsWith( + UnsupportedManagedRegistryVersionError, + ), + }); + + return { + prepareOrdinaryStack: (input) => + Effect.flatMap(requireOwnerPid(input.ownerPid), () => + transaction( + database, + () => prepareOrdinaryStack(database, input), + failsWith( + DuplicateManagedIdentityError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + ), + ), + publishPendingStack: (stackId, operationToken, now) => + transaction( + database, + () => publishPendingStack(database, stackId, operationToken, now), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + abortPendingStack: (stackId, operationToken) => + transaction( + database, + () => abortPendingStack(database, stackId, operationToken), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + getStack: (stackId) => readTransaction(database, () => getStack(database, stackId)), + listStacks: (options) => readTransaction(database, () => selectStacks(database, options)), + claimOperation: (input) => + Effect.flatMap(requireOwnerPid(input.ownerPid), () => + transaction( + database, + () => claimOperation(database, input), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + ), + finishOperation: (stackId, operationToken, outcome, now, error) => + transaction( + database, + () => finishOperation(database, stackId, operationToken, outcome, now, error), + failsWith(ManagedOperationOwnershipError), + ), + updateStack: (input) => + transaction( + database, + () => updateStack(database, input), + failsWith( + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + ), + ), + listActiveOperations: (startedBefore) => + Effect.sync(() => selectActiveOperations(database, startedBefore)), + reconcileOperation: (stackId, operationToken, lifecycle, now) => + transaction( + database, + () => reconcileOperation(database, stackId, operationToken, lifecycle, now), + failsWith( + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedStackNotFoundError, + ), + ), + tombstoneStack: (stackId, operationToken, now) => + transaction( + database, + () => tombstoneStack(database, stackId, operationToken, now), + failsWith( + ManagedOperationOwnershipError, + ManagedStackNotFoundError, + ), + ), + listCheckoutLocations: () => Effect.sync(() => selectCheckoutLocations(database)), + pruneCheckoutLocations: (locationIds) => + transaction(database, () => pruneCheckoutLocations(database, locationIds), neverFails), + }; + }); + +/** + * The registry stores workspace paths, ports, and credential references that + * other local users must not read. Pre-create the database file with an + * owner-only mode so it never exists with umask-derived permissions, and + * retighten both it and a directory left looser by an earlier build. Doing this + * before the WAL conversion also makes the -wal/-shm sidecars inherit the + * owner-only mode. + */ +export const hardenManagedRegistryFile = (path: string): void => { + if (path === ":memory:") { + return; + } + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + chmodSync(dirname(path), 0o700); + closeSync(openSync(path, "a", 0o600)); + chmodSync(path, 0o600); }; + +/** + * The registry as a scoped layer: the handle is opened when the layer is built + * and closed when its scope closes, including when schema initialization refuses + * the registry, so no failure path can leak an open database. + */ +export const sqliteManagedStackRepositoryLayer = ( + openDatabase: () => ManagedSqliteDatabase, +): Layer.Layer => + Layer.effect( + ManagedStackRepository, + Effect.gen(function* () { + const scope = yield* Effect.scope; + const database = yield* Effect.sync(openDatabase); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + database.close(); + }), + ); + return yield* createSqliteManagedStackRepository(database); + }), + ); From 29397eff4eb01603191a922f05f45984d9ecd732 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 13:30:09 +0200 Subject: [PATCH 15/18] test(stack): exercise the managed Effect surface and document the architecture - adopt @effect/vitest with an Effect-surface integration suite - document the Effect-native managed architecture and its Promise edge Co-Authored-By: Claude Fable 5 --- packages/stack/README.md | 52 ++- packages/stack/docs/architecture.md | 125 ++++++- .../src/managed-effect.integration.test.ts | 318 ++++++++++++++++++ 3 files changed, 478 insertions(+), 17 deletions(-) create mode 100644 packages/stack/src/managed-effect.integration.test.ts diff --git a/packages/stack/README.md b/packages/stack/README.md index 6c286a1c40..bf76deb03c 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -41,20 +41,60 @@ await stack.dispose(); ### Managed ordinary-folder state +The managed registry is an Effect API. `ManagedStackService` is the policy layer and +`ManagedStackRepository` is the storage contract; each has layer factories, failures arrive in the +error channel, and the registry handle is owned by a scope: + +```typescript +import { BunFileSystem } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; +import { + bunSqliteManagedStackRepositoryLayer, + managedRegistryPath, + ManagedStackService, +} from "@supabase/stack/managed"; + +const stateRoot = "/absolute/managed-state"; +const managedLayer = ManagedStackService.make({ stateRoot }).pipe( + Layer.provide(bunSqliteManagedStackRepositoryLayer(managedRegistryPath(stateRoot))), + Layer.provide(BunFileSystem.layer), +); + +const program = Effect.gen(function* () { + const managed = yield* ManagedStackService; + const result = yield* managed.provisionOrdinaryStack({ + workspacePath: "/absolute/project", + configuration: { + runtimeRequest: "docker", + serviceVersions: { postgres: "17.6.1.143" }, + }, + }); + console.log(result.stack.id, result.stack.paths.data); +}).pipe( + // Every method declares the failures it can raise, so recovery is typed. + Effect.catchTag("ManagedStackPublicationTimeoutError", (error) => + Effect.sync(() => console.log(`another process never published ${error.stackId}`)), + ), +); + +// The layer's scope owns the registry handle, so it closes with the scope. +await Effect.runPromise(Effect.scoped(Effect.provide(program, managedLayer))); +``` + +Callers that do not run an Effect runtime can use the Promise edge over the same layers. It builds +its runtime eagerly, so `inspectStack` and `listStacks` stay synchronous accessors and a registry +this process cannot open fails at creation rather than at the first call that touches it: + ```typescript import { createManagedStackService } from "@supabase/stack/managed"; const managed = createManagedStackService(); const result = await managed.provisionOrdinaryStack({ workspacePath: "/absolute/project", - configuration: { - runtimeRequest: "docker", - serviceVersions: { postgres: "17.6.1.143" }, - }, }); -console.log(result.stack.id, result.stack.paths.data); -managed.close(); +console.log(managed.inspectStack(result.stack.id)?.status); +await managed.close(); ``` Managed state uses opaque project, checkout, context, and stack UUIDs. A non-Git workspace stores diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 98f9761f89..0cd7fe131b 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -278,16 +278,19 @@ See [detach mode](./detach-mode.md) for paths, process startup, and compiled exe Here, **managed state** means the centralized registry API exposed from `@supabase/stack/managed`. It is distinct from the older `ManagedStack` daemon-discovery record in -`managed-stack.ts`, which remains part of the legacy Effect daemon surface. The registry API uses -Promises because its consumers perform short filesystem and SQLite coordination around the -Promise-oriented `createStack()` boundary; the runtime lifecycle beneath it remains Effect-based. -Its errors are `Data.TaggedError` classes carrying stable `code` fields, and there is no shared base -class: `ManagedStackError` is a union type over the seventeen failures, with `isManagedStackError` -as the runtime guard. `_tag` is the Effect-native discriminant, so an Effect consumer can -`catchTag` them directly; `code` is the wire-level contract that survives identifier minification, -so Node and Bun callers — and the CLI's telemetry classifier — can branch on failures without -requiring an Effect runtime at this persistence boundary. `MANAGED_ERROR_TAG_BY_CODE` links the two -so a consumer keying a table by one and dispatching on the other cannot drift. +`managed-stack.ts`, which remains part of the legacy Effect daemon surface. The registry API is +Effect-native: its services are `Context.Service` tags, its failures live in the effect error +channel, and its resources are owned by scopes. A Promise facade sits at the edge for callers that +do not run an Effect runtime; see "Managed service composition" below. + +Managed errors are `Data.TaggedError` classes carrying stable `code` fields, and there is no shared +base class: `ManagedStackError` is a union type over the seventeen failures, with +`isManagedStackError` as the runtime guard. `_tag` is the Effect-native discriminant, so a consumer +can `catchTag` them directly against the union a given method declares; `code` is the wire-level +contract that survives identifier minification, so Node and Bun callers — and the CLI's telemetry +classifier — can branch on failures without requiring an Effect runtime at this persistence +boundary. `MANAGED_ERROR_TAG_BY_CODE` links the two so a consumer keying a table by one and +dispatching on the other cannot drift. The managed surface owns a versioned SQLite registry with separate records for projects, checkouts, checkout locations, development contexts, stacks, port reservations, and operations. @@ -338,7 +341,9 @@ unregistered UUID stack root when a provisioner writes after its pending row was aborted. The provision error reports the failed ownership cleanup, but there is no automatic orphan garbage collection; remove that root only after independently confirming its runtime is stopped. -Stack publication and operation claims are transactional. A new stack remains `pending` while its +Stack publication and operation claims are transactional; "Managed service composition" below +describes how that transaction boundary and the wait for a concurrent publisher are expressed. A new +stack remains `pending` while its directories and caller-supplied initialization are validated, then becomes `active` atomically. Concurrent callers resolve the published record rather than creating aliases. Recovery first retains claims whose owner process is still alive. Once an owner is gone, runtime inspection either @@ -400,6 +405,99 @@ port-occupying stack stopped and releasing its lease. Runtime qualification, leg selection, and credential resolution remain outside this persistence boundary and are composed by later CLI slices. +## Managed service composition + +The managed surface is two `Context.Service` tags, each with layer factories: + +- `ManagedStackRepository` is the storage contract. It is provided by + `bunSqliteManagedStackRepositoryLayer(path)` or `nodeSqliteManagedStackRepositoryLayer(path)` — + re-exported from `managed-bun.ts` and `managed-node.ts` respectively — or, in tests, by + `Layer.succeed(ManagedStackRepository, createInMemoryManagedStackRepository())`, since the + in-memory factory from `@supabase/stack/testing` returns the Effect-shaped service object directly. + The contract contains no SQLite types, so the adapter is swappable without the policy layer + noticing. Opening a registry whose schema version is neither zero nor the supported version fails + the layer with `UnsupportedManagedRegistryVersionError`. +- `ManagedStackService` is the policy layer described above: identity markers, provisioning order, + publication waiting, deletion, and recovery. `ManagedStackService.make(options)` returns a layer + requiring `FileSystem.FileSystem | ManagedStackRepository` and failing with + `InvalidManagedOwnerPidError | UnsafeManagedStackPathError`, so a blank state root or an owner PID + that could never be probed is refused while the layer is being built rather than at whichever call + first touches a path. + +Each method declares only the failures it can actually raise, rather than one service-wide union: +`provisionOrdinaryStack` carries `ProvisionManagedStackFailure`, `updateStack` carries +`UpdateManagedStackConfigurationFailure`, `deleteStack` carries `DeleteManagedStackFailure`, +`inspectOrdinaryWorkspace` carries only `InvalidManagedIdentityError`, and `inspectStack` and +`listStacks` cannot fail at all. `deleteStack` and `pruneCheckoutLocations` are additionally generic +in their callback's error type, so a `stop` callback's own failure reaches the caller unchanged — a +stack that refused to stop was not deleted. Recovery reports rather than fails: only a forced target +that is not a pair of managed UUIDs refuses a whole pass, so `reconcileAbandonedOperations` declares +just `InvalidManagedIdentityError` and returns retained claims, skips, and failures in its result. + +Registry decisions are transactions written as `Effect.acquireUseRelease`. The acquire opens +`BEGIN IMMEDIATE` (or `BEGIN` for read paths), the use runs the decision, and the release inspects +the `Exit` to commit on success and roll back on failure — so an interrupted fiber cannot leave a +transaction open, and a rollback never masks the original cause. The decision itself stays a +synchronous closure: the drivers are synchronous, and a partially applied decision must never be +observable. + +The database handle's lifetime is a scope. `sqliteManagedStackRepositoryLayer` opens the file when +the layer is built and registers a finalizer that closes it, including on the path where schema +initialization refuses the registry, so no failure path leaks an open handle. Closing the scope that +built the layer closes the registry. + +Waiting for a concurrent publisher is `Schedule`-driven. One look at the pending row is a retryable +step — a still-pending row asks for another look, while a vanished or tombstoned row is a final +answer — repeated on `Schedule.exponential` from `publicationPollMs` with a 250 ms ceiling, so a slow +publisher is not polled hundreds of times per second for the whole window. The ceiling only ever +slows polling down, so a caller asking for a slower interval keeps its own. `publicationTimeoutMs` is +the caller's bound on the entire wait and is applied as a timeout around the repeat, so it interrupts +the poll instead of being checked between polls. + +An Effect consumer uses the tags directly, which is the primary API: + +```typescript +import { BunFileSystem } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; +import { bunSqliteManagedStackRepositoryLayer, ManagedStackService } from "@supabase/stack/managed"; + +const managedLayer = ManagedStackService.make({ stateRoot }).pipe( + Layer.provide(bunSqliteManagedStackRepositoryLayer(registryPath)), + Layer.provide(BunFileSystem.layer), +); + +const program = Effect.gen(function* () { + const managed = yield* ManagedStackService; + return yield* managed.provisionOrdinaryStack({ workspacePath }); +}).pipe( + Effect.catchTag("ManagedStackPublicationTimeoutError", (error) => + Effect.fail(`another process never published ${error.stackId}`), + ), +); +``` + +`createManagedStackService()` — and `makeManagedStackService()` over a repository the caller already +has — is a thin `ManagedRuntime` edge over exactly those layers, for consumers that do not run an +Effect runtime. It exists to serve the Promise-oriented `createStack()` boundary; the runtime +lifecycle beneath it is Effect-based either way. Two properties of that edge are contracts rather +than incidental: + +- **Construction is eager and synchronous.** The facade builds the runtime's context with + `Effect.runSync`, so the registry is opened while the service is being created. That keeps + `inspectStack` and `listStacks` synchronous accessors over a synchronous handle, matching how + callers read the registry inline while deciding what to do next, and it makes a registry this + process cannot open fail at creation rather than at whichever later call happens to touch it first. +- **The cold-start WAL retry is therefore synchronous too.** Converting a fresh registry to WAL can + lose a race with another process doing the same thing, so `enableWriteAheadLogging` retries + `SQLITE_BUSY` with a blocking `Atomics.wait`. Expressing that retry as an Effect schedule would + make layer construction asynchronous, which the synchronous-construction contract above forbids. + The retry stays synchronous by design for now; making it an Effect retry is gated on the facade + giving up its synchronous accessors. + +`close()` disposes the `ManagedRuntime`, which closes the scope that owns the database handle. The +facade also hands back the very repository the service uses, so an embedder can read the registry +without opening a second handle on it. + ## Legacy daemon paths The pre-managed daemon implementation still reads its project-keyed state as a legacy/bootstrap @@ -457,6 +555,11 @@ not be used for new managed records. factories, topology, projection, cleanup metadata, and protocol schemas. - Integration tests exercise binary publication, lifecycle coordination, daemon HTTP/SSE, remote stack behavior, state persistence, and Unix socket streaming with stateful Effect Adapters. +- The managed registry is covered from both of its surfaces. `managed-service.integration.test.ts` + carries the behavioral load through the Promise facade against the in-memory and both SQLite + adapters, while `managed-effect.integration.test.ts` uses `@effect/vitest` to hold the Effect + surface itself to account: the tags composed as layers, typed failures recovered with `catchTag`, + and the scoped registry handle released when its scope closes. - Targeted e2e tests own the expensive process/container Seam for full stack startup, parallel stacks, daemon lifecycle, and cleanup behavior. diff --git a/packages/stack/src/managed-effect.integration.test.ts b/packages/stack/src/managed-effect.integration.test.ts new file mode 100644 index 0000000000..098aea97b7 --- /dev/null +++ b/packages/stack/src/managed-effect.integration.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunFileSystem } from "@effect/platform-bun"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach } from "vitest"; +import { Effect, Exit, Layer } from "effect"; +import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; +import { + ManagedStackPublicationTimeoutError, + type UnsupportedManagedRegistryVersionError, +} from "./managed/model.ts"; +import { managedRegistryPath, managedStackPaths } from "./managed/paths.ts"; +import { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; +import { ManagedStackRepository } from "./managed/repository.ts"; +import { ManagedStackService, type ManagedStackServiceOptions } from "./managed/service.ts"; +import { bunSqliteManagedStackRepositoryLayer } from "./managed/sqlite-bun.ts"; + +/** + * The Effect surface of the managed registry, exercised as an Effect consumer + * uses it: `yield* ManagedStackService` over a repository layer, typed failures + * recovered with `Effect.catchTag`, and the registry handle owned by a scope. + * + * The Promise facade's suite in `managed-service.integration.test.ts` carries the + * behavioral load. This suite exists to prove the Effect API is a first-class + * entrypoint rather than an implementation detail behind that facade. + */ + +const temporaryRoots: Array = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +const makeRoot = (): string => { + const root = mkdtempSync(join(tmpdir(), "managed-effect-test-")); + temporaryRoots.push(root); + return root; +}; + +const makeWorkspace = (root: string, name = "workspace"): string => { + const workspace = join(root, name); + mkdirSync(workspace, { recursive: true }); + return workspace; +}; + +type ServiceOverrides = Omit; + +/** + * The layer an Effect consumer assembles: the policy service over a repository + * adapter over the platform filesystem. The repository is merged rather than only + * provided so a test can drive the registry directly to stage a scenario. + */ +const managedLayer = ( + stateRoot: string, + repositoryLayer: Layer.Layer, + overrides: ServiceOverrides, +) => + ManagedStackService.make({ stateRoot, publicationPollMs: 1, ...overrides }).pipe( + Layer.provideMerge(repositoryLayer), + Layer.provide(BunFileSystem.layer), + ); + +const setupInMemory = (overrides: ServiceOverrides = {}) => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + return { + root, + stateRoot, + workspace: makeWorkspace(root), + layer: managedLayer( + stateRoot, + Layer.succeed(ManagedStackRepository, createInMemoryManagedStackRepository()), + overrides, + ), + }; +}; + +const setupSqlite = (overrides: ServiceOverrides = {}) => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + return { + root, + stateRoot, + workspace: makeWorkspace(root), + /** A fresh handle on the same registry file, the way a second process opens it. */ + openRegistry: () => + managedLayer( + stateRoot, + bunSqliteManagedStackRepositoryLayer(managedRegistryPath(stateRoot)), + overrides, + ), + }; +}; + +/** + * Stages a pending stack whose publisher is alive but will never publish, so the + * next provision of that workspace has to wait for a publication that never lands. + */ +const stagePendingStack = (workspace: string, stateRoot: string) => + Effect.gen(function* () { + const repository = yield* ManagedStackRepository; + const { identity } = yield* ensureOrdinaryWorkspaceIdentity(workspace); + const stackId = crypto.randomUUID(); + const prepared = yield* repository.prepareOrdinaryStack({ + identity, + canonicalPath: realpathSync(workspace), + locationId: crypto.randomUUID(), + stackId, + stackName: "default", + paths: managedStackPaths(stateRoot, stackId), + operationToken: crypto.randomUUID(), + ownerPid: process.pid, + now: "2026-08-11T00:00:00.000Z", + configuration: {}, + }); + if (prepared.outcome !== "create") { + return yield* Effect.die(new Error("Expected to stage a pending managed stack")); + } + mkdirSync(prepared.stack.paths.data, { recursive: true }); + return prepared.stack; + }); + +describe("managed stack Effect surface", () => { + it.effect("provisions a stack for a new workspace and reuses it on the next call", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const created = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + const reused = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + + expect(created.outcome).toBe("create"); + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.id).toBe(created.stack.id); + expect(reused.selection).toEqual(created.selection); + expect(existsSync(created.stack.paths.data)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.effect("adopts a caller's configuration when it reuses a stack", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + const reused = yield* managed.provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { runtimeRequest: "docker" }, + }); + + expect(reused.outcome).toBe("reuse"); + expect(reused.stack.runtimeRequest).toBe("docker"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports an unregistered workspace before anything is provisioned", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const before = yield* managed.inspectOrdinaryWorkspace(workspace); + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + const after = yield* managed.inspectOrdinaryWorkspace(workspace); + + expect(before).toEqual({ registered: false, stacks: [] }); + expect(after.registered).toBe(true); + expect(after.stacks.map((candidate) => candidate.id)).toEqual([stack.id]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lets a caller recover from a rejected stack name with catchTag", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + // The failure is in the effect's error channel, so the recovery is typed: + // `catchTag` narrows to the one failure and its payload without a cast. + const outcome = yield* managed + .provisionOrdinaryStack({ workspacePath: workspace, stackName: "Not A Name" }) + .pipe( + Effect.catchTag("InvalidManagedStackNameError", (error) => + Effect.succeed(`rejected ${error.stackName}`), + ), + ); + const stacks = yield* managed.listStacks(); + + expect(outcome).toBe("rejected Not A Name"); + expect(stacks).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a stopped-stack requirement rather than deleting a running stack", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + yield* managed.updateStack(stack.id, { lifecycle: "running" }); + + const refused = yield* managed + .deleteStack(stack.id) + .pipe( + Effect.catchTag("ManagedStackNotStoppedError", (error) => Effect.succeed(error._tag)), + ); + const survivor = yield* managed.inspectStack(stack.id); + + expect(refused).toBe("ManagedStackNotStoppedError"); + expect(survivor?.lifecycle).toBe("running"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("deletes a stack once and treats a repeated delete as a no-op", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + + const deleted = yield* managed.deleteStack(stack.id); + const repeated = yield* managed.deleteStack(stack.id); + + expect(deleted.outcome).toBe("delete"); + expect(deleted.dataReclamation.outcome).toBe("removed"); + expect(repeated.outcome).toBe("no-op"); + expect(existsSync(stack.paths.root)).toBe(false); + expect((yield* managed.inspectStack(stack.id))?.status).toBe("tombstoned"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("propagates a stop callback's own failure type out of deleteStack", () => { + const { workspace, layer } = setupInMemory(); + class StopRefused { + readonly _tag = "StopRefused"; + } + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + yield* managed.updateStack(stack.id, { lifecycle: "running" }); + + const exit = yield* managed + .deleteStack(stack.id, { stop: () => Effect.fail(new StopRefused()) }) + .pipe(Effect.exit); + const survivor = yield* managed.inspectStack(stack.id); + + expect(Exit.isFailure(exit)).toBe(true); + expect(survivor?.status).toBe("active"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps a stack visible to a registry handle opened after the first one closed", () => { + const { workspace, stateRoot, openRegistry } = setupSqlite(); + return Effect.gen(function* () { + // The registry handle belongs to the layer's scope, so each `Effect.scoped` + // block opens the file, uses it, and closes it before the next block runs. + const provisioned = yield* Effect.gen(function* () { + const managed = yield* ManagedStackService; + const repository = yield* ManagedStackRepository; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + expect(yield* repository.getStack(stack.id)).toMatchObject({ id: stack.id }); + return stack; + }).pipe(Effect.scoped, Effect.provide(openRegistry())); + + expect(existsSync(managedRegistryPath(stateRoot))).toBe(true); + + const reopened = yield* Effect.gen(function* () { + const managed = yield* ManagedStackService; + return yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + }).pipe(Effect.scoped, Effect.provide(openRegistry())); + + expect(reopened.outcome).toBe("reuse"); + expect(reopened.stack.id).toBe(provisioned.id); + }); + }); + + it.live("gives up on a pending stack whose publisher never publishes", () => { + // Deliberately `it.live` with a tiny window rather than `TestClock`. + // `TestClock.adjust` only releases sleeps that are already registered, and + // provision does real identity and registry I/O before it reaches its first + // poll, so a forked provision has not parked yet when the adjustment runs: + // the advance passes through, no sleep is released, and the join never + // returns. A two-millisecond real deadline is the honest bound here. + const { workspace, stateRoot, layer } = setupInMemory({ + publicationTimeoutMs: 2, + publicationPollMs: 1, + isProcessAlive: () => true, + }); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const pending = yield* stagePendingStack(workspace, stateRoot); + + const timedOut = yield* managed + .provisionOrdinaryStack({ workspacePath: workspace }) + .pipe( + Effect.catchTag("ManagedStackPublicationTimeoutError", (error) => Effect.succeed(error)), + ); + const stacks = yield* managed.listStacks(); + + expect(timedOut).toBeInstanceOf(ManagedStackPublicationTimeoutError); + expect(stacks.map((stack) => stack.id)).toEqual([pending.id]); + expect(stacks[0]?.status).toBe("pending"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refuses to build a service over a blank state root", () => { + // A blank root would anchor every managed path to the process' working + // directory, so the layer must fail while it is being built rather than at + // whichever call first touches a path. + const layer = managedLayer( + "", + Layer.succeed(ManagedStackRepository, createInMemoryManagedStackRepository()), + {}, + ); + return Effect.gen(function* () { + const exit = yield* Effect.gen(function* () { + return yield* ManagedStackService; + }).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }); + }); +}); From c39b3f0dc04053a4977ffb61af6be060725a3f3c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 13:48:20 +0200 Subject: [PATCH 16/18] refactor(stack): acquire the managed promise edge asynchronously - construct the facade through the effect runtime with typed rejections - make handle reads promise-returning and support await-using disposal - replace the blocking WAL contention retry with a scheduled effect retry Co-Authored-By: Claude Fable 5 --- packages/stack/README.md | 11 +- packages/stack/docs/architecture.md | 36 +- packages/stack/src/managed-bun.ts | 4 +- packages/stack/src/managed-node.ts | 4 +- .../src/managed-service.integration.test.ts | 350 ++++++++++-------- packages/stack/src/managed/create-service.ts | 47 ++- packages/stack/src/managed/sqlite.ts | 96 +++-- 7 files changed, 327 insertions(+), 221 deletions(-) diff --git a/packages/stack/README.md b/packages/stack/README.md index bf76deb03c..61255536a1 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -81,20 +81,19 @@ const program = Effect.gen(function* () { await Effect.runPromise(Effect.scoped(Effect.provide(program, managedLayer))); ``` -Callers that do not run an Effect runtime can use the Promise edge over the same layers. It builds -its runtime eagerly, so `inspectStack` and `listStacks` stay synchronous accessors and a registry -this process cannot open fails at creation rather than at the first call that touches it: +Callers that do not run an Effect runtime can use the Promise edge over the same layers. Acquiring it +is I/O, so it is awaited and a registry this process cannot open rejects there rather than at the +first call that touches it. The handle is an `AsyncDisposable`, so `await using` closes it: ```typescript import { createManagedStackService } from "@supabase/stack/managed"; -const managed = createManagedStackService(); +await using managed = await createManagedStackService(); const result = await managed.provisionOrdinaryStack({ workspacePath: "/absolute/project", }); -console.log(managed.inspectStack(result.stack.id)?.status); -await managed.close(); +console.log((await managed.inspectStack(result.stack.id))?.status); ``` Managed state uses opaque project, checkout, context, and stack UUIDs. A non-Git workspace stores diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 0cd7fe131b..deb2d596f6 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -479,24 +479,32 @@ const program = Effect.gen(function* () { `createManagedStackService()` — and `makeManagedStackService()` over a repository the caller already has — is a thin `ManagedRuntime` edge over exactly those layers, for consumers that do not run an Effect runtime. It exists to serve the Promise-oriented `createStack()` boundary; the runtime -lifecycle beneath it is Effect-based either way. Two properties of that edge are contracts rather +lifecycle beneath it is Effect-based either way. Three properties of that edge are contracts rather than incidental: -- **Construction is eager and synchronous.** The facade builds the runtime's context with - `Effect.runSync`, so the registry is opened while the service is being created. That keeps - `inspectStack` and `listStacks` synchronous accessors over a synchronous handle, matching how - callers read the registry inline while deciding what to do next, and it makes a registry this - process cannot open fail at creation rather than at whichever later call happens to touch it first. -- **The cold-start WAL retry is therefore synchronous too.** Converting a fresh registry to WAL can - lose a race with another process doing the same thing, so `enableWriteAheadLogging` retries - `SQLITE_BUSY` with a blocking `Atomics.wait`. Expressing that retry as an Effect schedule would - make layer construction asynchronous, which the synchronous-construction contract above forbids. - The retry stays synchronous by design for now; making it an Effect retry is gated on the facade - giving up its synchronous accessors. +- **Acquisition is asynchronous.** Both factories return a `Promise` and + build the runtime's context through `runtime.context()`, because opening the registry is I/O: a + file is created and hardened, its schema read, and a cold start may have to wait out another + process' WAL conversion. Everything that can refuse the acquisition arrives as a rejection — a + blank state root, an owner PID that could never be probed, and a registry written by an + unsupported schema version all reject with the same typed error instances, so a caller has one + failure channel instead of a throw plus a rejection. +- **Reads are Promises too.** `inspectStack` and `listStacks` return Promises rather than answering + inline. A handle that read synchronously would only be hiding the registry's I/O from its caller, + and it is what forced the cold-start retry below to block. The `repository` accessor stays a plain + property: the context is already resolved by the time a caller holds the handle. +- **The cold-start WAL retry is a schedule, not a blocking wait.** Converting a fresh registry to + WAL can lose a race with another process doing the same thing, so `enableWriteAheadLogging` + retries exactly the `SQLITE_BUSY`/`SQLITE_LOCKED` classification on `Schedule.exponential` from + 10 ms, capped at 100 ms per wait and bounded to a total ~4 s budget. Contention that never clears + surfaces the driver's own busy error, as an immediate non-busy failure of that pragma always has. + Because the retry suspends the fiber instead of spinning on `Atomics.wait`, a process opening the + registry no longer stalls the event loop that every other caller in it depends on. `close()` disposes the `ManagedRuntime`, which closes the scope that owns the database handle. The -facade also hands back the very repository the service uses, so an embedder can read the registry -without opening a second handle on it. +handle is also an `AsyncDisposable`, so `await using service = await createManagedStackService()` +closes it on every path out of the block. The facade hands back the very repository the service uses, +so an embedder can read the registry without opening a second handle on it. ## Legacy daemon paths diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts index 755f77dca9..7932fad8d4 100644 --- a/packages/stack/src/managed-bun.ts +++ b/packages/stack/src/managed-bun.ts @@ -13,9 +13,9 @@ export { bunSqliteManagedStackRepositoryLayer }; export const createManagedStackService = ( options: CreateManagedStackServiceOptions = {}, -): ManagedStackServiceHandle => +): Promise => createManagedStackServiceWith(BunFileSystem.layer, bunSqliteManagedStackRepositoryLayer, options); export const makeManagedStackService = ( options: MakeManagedStackServiceOptions, -): ManagedStackServiceHandle => makeManagedStackServiceWith(BunFileSystem.layer, options); +): Promise => makeManagedStackServiceWith(BunFileSystem.layer, options); diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts index 3aaa37a579..c4efee9d2b 100644 --- a/packages/stack/src/managed-node.ts +++ b/packages/stack/src/managed-node.ts @@ -13,7 +13,7 @@ export { nodeSqliteManagedStackRepositoryLayer }; export const createManagedStackService = ( options: CreateManagedStackServiceOptions = {}, -): ManagedStackServiceHandle => +): Promise => createManagedStackServiceWith( NodeFileSystem.layer, nodeSqliteManagedStackRepositoryLayer, @@ -22,4 +22,4 @@ export const createManagedStackService = ( export const makeManagedStackService = ( options: MakeManagedStackServiceOptions, -): ManagedStackServiceHandle => makeManagedStackServiceWith(NodeFileSystem.layer, options); +): Promise => makeManagedStackServiceWith(NodeFileSystem.layer, options); diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 0b2672bf48..4a769ce906 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -54,22 +54,27 @@ import { } from "./managed-bun.ts"; /** - * Both registry adapters decide synchronously, so a test can run a contract call - * the same way the Promise facade's synchronous accessors do. + * Both registry adapters decide synchronously once they are open, so a test can + * run a contract call inline instead of awaiting it. */ const runRepo = Effect.runSync; /** * Opens a registry the way production does, as a scoped layer, for the tests that - * exercise the SQLite adapter itself rather than a managed stack service. The - * layer's scope owns the database handle, so it stays open until `close`. + * exercise the SQLite adapter itself rather than a managed stack service. Opening + * it is I/O — a cold start may wait out another process' WAL conversion — so the + * layer is built through a Promise, and the layer's scope owns the database + * handle until `close`. */ -const openRegistry = ( +const openRegistry = async ( databasePath: string, -): { readonly repository: ManagedStackRepositoryShape; readonly close: () => Promise } => { +): Promise<{ + readonly repository: ManagedStackRepositoryShape; + readonly close: () => Promise; +}> => { const runtime = ManagedRuntime.make(bunSqliteManagedStackRepositoryLayer(databasePath)); return { - repository: Context.get(Effect.runSync(runtime.contextEffect), ManagedStackRepository), + repository: Context.get(await runtime.context(), ManagedStackRepository), close: () => runtime.dispose(), }; }; @@ -115,7 +120,7 @@ type ServiceOverrides = Omit +): Promise => makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), @@ -126,7 +131,7 @@ const makeInMemoryService = ( const makePersistentService = ( root: string, overrides: ServiceOverrides = {}, -): ManagedStackServiceHandle => +): Promise => createManagedStackService({ stateRoot: join(root, "managed"), publicationPollMs: 1, @@ -207,7 +212,7 @@ const prepareAbandonedStack = async ( describe("ordinary-folder managed stack contract", () => { it("restricts registry and stack state permissions to the owning user", async () => { const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); const stateRoot = join(root, "managed"); const { stack } = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), @@ -229,7 +234,7 @@ describe("ordinary-folder managed stack contract", () => { mkdirSync(stateRoot, { recursive: true, mode: 0o755 }); writeFileSync(registryPath, "", { mode: 0o644 }); - const service = makePersistentService(root); + const service = await makePersistentService(root); await service.close(); const modeOf = (path: string): number => statSync(path).mode & 0o777; @@ -240,7 +245,7 @@ describe("ordinary-folder managed stack contract", () => { it("keeps read-only discovery registration-free", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); const result = await service.inspectOrdinaryWorkspace(workspace); @@ -253,7 +258,7 @@ describe("ordinary-folder managed stack contract", () => { it("reports an existing identity without stacks as not yet registered", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); const marker = await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace)); const result = await service.inspectOrdinaryWorkspace(workspace); @@ -272,7 +277,7 @@ describe("ordinary-folder managed stack contract", () => { foreignContextStack === undefined ? stacks : [...stacks, foreignContextStack], ), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: filteringRepository, stateRoot: join(root, "managed"), }); @@ -294,7 +299,7 @@ describe("ordinary-folder managed stack contract", () => { it("fails safely on an unknown newer workspace identity marker", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); const markerPath = ordinaryWorkspaceIdentityPath(workspace); mkdirSync(join(workspace, ".supabase")); writeFileSync( @@ -317,20 +322,20 @@ describe("ordinary-folder managed stack contract", () => { it.each(invalidStackNameCases)("rejects %s", async (_fixtureId, stackName) => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); const provision = service.provisionOrdinaryStack({ workspacePath: workspace, stackName }); await expect(provision).rejects.toBeInstanceOf(InvalidManagedStackNameError); await expect(provision).rejects.toThrow(`Invalid managed stack name: ${stackName}`); expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); - expect(service.listStacks()).toEqual([]); + expect(await service.listStacks()).toEqual([]); }); it("resolves every valid fixture stack name within one ordinary context", async () => { const names = stackNames("identity.valid-stack-names-resolve-deterministically"); const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); const results = await Promise.all( names.map((stackName) => @@ -347,7 +352,7 @@ describe("ordinary-folder managed stack contract", () => { const recoveredStart = fixture("identity.non-git-folder-recovers-persisted-identity"); const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makePersistentService(root); + const service = await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: workspace, @@ -393,14 +398,14 @@ describe("ordinary-folder managed stack contract", () => { }); await service.close(); - const reopened = makePersistentService(root); + const reopened = await makePersistentService(root); const reused = await reopened.provisionOrdinaryStack({ workspacePath: workspace }); expect(reused.outcome).toBe(recoveredStart.expected.outcome); expect(reused.identityMarkerCreated).toBe(false); expect(reused.selection).toEqual(created.selection); expect(reused.stack.ports).toEqual(created.stack.ports); - expect(reopened.listStacks()).toHaveLength(1); + expect(await reopened.listStacks()).toHaveLength(1); await reopened.close(); const registry = new Database(managedRegistryPath(join(root, "managed"))); @@ -420,7 +425,7 @@ describe("ordinary-folder managed stack contract", () => { const workspace = makeWorkspace(root); const repository = createInMemoryManagedStackRepository(); const stateRoot = join(root, "isolated-managed-state"); - const service = makeManagedStackService({ repository, stateRoot }); + const service = await makeManagedStackService({ repository, stateRoot }); const result = await service.provisionOrdinaryStack({ workspacePath: workspace }); @@ -434,7 +439,7 @@ describe("ordinary-folder managed stack contract", () => { const contract = fixture("identity.concurrent-create-publishes-once"); const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makePersistentService(root); + const service = await makePersistentService(root); let releaseInitialization: () => void = () => {}; const initializationGate = new Promise((resolve) => { releaseInitialization = resolve; @@ -467,7 +472,7 @@ describe("ordinary-folder managed stack contract", () => { const requested = { key: "api.port", port: 55_451, intent: "exact" } as const; const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makePersistentService(root); + const service = await makePersistentService(root); let releaseInitialization: () => void = () => {}; const initializationGate = new Promise((resolve) => { releaseInitialization = resolve; @@ -494,7 +499,7 @@ describe("ordinary-folder managed stack contract", () => { expect(reused.stack.id).toBe(created.stack.id); expect(reused.stack.ports).toEqual([requested]); expect(reused.stack.serviceVersions).toEqual({ postgres: "17.6.1.143" }); - expect(service.inspectStack(created.stack.id)).toMatchObject({ + expect(await service.inspectStack(created.stack.id)).toMatchObject({ ports: [requested], serviceVersions: { postgres: "17.6.1.143" }, }); @@ -504,7 +509,7 @@ describe("ordinary-folder managed stack contract", () => { it("rolls back failed initialization and makes the same start retryable", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makePersistentService(root); + const service = await makePersistentService(root); let failedRoot: string | undefined; await expect( @@ -519,12 +524,12 @@ describe("ordinary-folder managed stack contract", () => { expect(failedRoot).toBeDefined(); expect(existsSync(failedRoot ?? "")).toBe(false); - expect(service.listStacks()).toEqual([]); + expect(await service.listStacks()).toEqual([]); expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(true); const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); expect(retried.outcome).toBe("create"); - expect(service.listStacks()).toHaveLength(1); + expect(await service.listStacks()).toHaveLength(1); await service.close(); }); @@ -532,7 +537,7 @@ describe("ordinary-folder managed stack contract", () => { const root = makeRoot(); const firstWorkspace = makeWorkspace(root, "first"); const secondWorkspace = makeWorkspace(root, "copy"); - const service = makePersistentService(root); + const service = await makePersistentService(root); await service.provisionOrdinaryStack({ workspacePath: firstWorkspace }); mkdirSync(join(secondWorkspace, ".supabase"), { recursive: true }); copyFileSync( @@ -543,7 +548,7 @@ describe("ordinary-folder managed stack contract", () => { await expect( service.provisionOrdinaryStack({ workspacePath: secondWorkspace }), ).rejects.toBeInstanceOf(DuplicateManagedIdentityError); - expect(service.listStacks()).toHaveLength(1); + expect(await service.listStacks()).toHaveLength(1); expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); await service.close(); }); @@ -551,7 +556,7 @@ describe("ordinary-folder managed stack contract", () => { it("times out without adopting a pending stack owned by another caller", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makePersistentService(root, { + const service = await makePersistentService(root, { publicationTimeoutMs: 2, publicationPollMs: 1, }); @@ -560,7 +565,7 @@ describe("ordinary-folder managed stack contract", () => { await expect( service.provisionOrdinaryStack({ workspacePath: workspace }), ).rejects.toBeInstanceOf(ManagedStackPublicationTimeoutError); - expect(service.listStacks()).toHaveLength(1); + expect(await service.listStacks()).toHaveLength(1); await service.close(); }); @@ -584,7 +589,7 @@ describe("ordinary-folder managed stack contract", () => { : prepared, ), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: corruptedRepository, stateRoot: join(root, "managed"), publicationTimeoutMs: 5_000, @@ -618,7 +623,7 @@ describe("ordinary-folder managed stack contract", () => { return repository.getStack(stackId); }), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: observedRepository, stateRoot: join(root, "managed"), publicationTimeoutMs: 1_600, @@ -643,7 +648,7 @@ describe("ordinary-folder managed stack contract", () => { const root = makeRoot(); const workspace = makeWorkspace(root); await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace)); - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), idFactory: () => "../../outside", @@ -653,7 +658,7 @@ describe("ordinary-folder managed stack contract", () => { service.provisionOrdinaryStack({ workspacePath: workspace }), ).rejects.toBeInstanceOf(InvalidManagedIdentityError); expect(existsSync(join(root, "outside"))).toBe(false); - expect(service.listStacks()).toEqual([]); + expect(await service.listStacks()).toEqual([]); }); }); @@ -664,17 +669,17 @@ describe("managed service options", () => { ["tab", "\t"], ])( "refuses an %s state root instead of falling back to the working directory", - (_case, stateRoot) => { - expect(() => + async (_case, stateRoot) => { + await expect( makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot, }), - ).toThrow(UnsafeManagedStackPathError); + ).rejects.toBeInstanceOf(UnsafeManagedStackPathError); }, ); - it("refuses an undefined state root instead of falling back to SUPABASE_HOME or the home directory", () => { + it("refuses an undefined state root instead of falling back to SUPABASE_HOME or the home directory", async () => { // `stateRoot` is required in the option type, but a caller bypassing the // type system (or a plain-JS caller) could still pass `undefined`. That // must fail loudly instead of silently resolving against SUPABASE_HOME or @@ -684,12 +689,12 @@ describe("managed service options", () => { const originalSupabaseHome = process.env["SUPABASE_HOME"]; process.env["SUPABASE_HOME"] = configuredHome; try { - expect(() => + await expect( makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: undefined, } as unknown as MakeManagedStackServiceOptions), - ).toThrow(UnsafeManagedStackPathError); + ).rejects.toBeInstanceOf(UnsafeManagedStackPathError); expect(existsSync(configuredHome)).toBe(false); } finally { if (originalSupabaseHome === undefined) { @@ -702,29 +707,29 @@ describe("managed service options", () => { it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( "refuses %s as an operation owner pid", - (ownerPid) => { + async (ownerPid) => { const root = makeRoot(); - expect(() => + await expect( makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), ownerPid, }), - ).toThrow(InvalidManagedOwnerPidError); + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); }, ); it("validates owner pids on the shared entrypoint options path too", async () => { const root = makeRoot(); - expect(() => + await expect( createManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), ownerPid: 0, }), - ).toThrow(InvalidManagedOwnerPidError); + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); - const service = createManagedStackService({ + const service = await createManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: join(root, "managed"), ownerPid: 4321, @@ -732,6 +737,31 @@ describe("managed service options", () => { expect(service.stateRoot).toBe(join(root, "managed")); await service.close(); }); + + it("closes a service acquired with await using when its block ends", async () => { + const root = makeRoot(); + let acquired: ManagedStackServiceHandle | undefined; + { + await using service = await makePersistentService(root); + acquired = service; + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + expect(await service.inspectStack(created.stack.id)).toMatchObject({ status: "active" }); + } + + if (acquired === undefined) { + throw new Error("Expected the disposed handle to be captured"); + } + // Leaving the block disposed the runtime that owns the registry, so the + // repository the service handed out is closed along with it. + const disposed = acquired; + expect(() => runRepo(disposed.repository.listStacks())).toThrow(); + + const reopened = await makePersistentService(root); + expect(await reopened.listStacks()).toHaveLength(1); + await reopened.close(); + }); }); describe("managed repository and lifecycle", () => { @@ -748,8 +778,8 @@ describe("managed repository and lifecycle", () => { }; const service = adapter === "in-memory" - ? makeInMemoryService(root, overrides) - : makePersistentService(root, overrides); + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); const first = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "Projects"), }); @@ -759,7 +789,7 @@ describe("managed repository and lifecycle", () => { expect(first.stack.createdAt).toBe(second.stack.createdAt); expect(second.stack.id < first.stack.id).toBe(true); - expect(service.listStacks().map((stack) => stack.id)).toEqual( + expect((await service.listStacks()).map((stack) => stack.id)).toEqual( [first.stack.id, second.stack.id].sort(), ); @@ -778,7 +808,9 @@ describe("managed repository and lifecycle", () => { const root = makeRoot(); const workspace = makeWorkspace(root); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: workspace }); const reused = await service.provisionOrdinaryStack({ workspacePath: workspace }); @@ -792,7 +824,7 @@ describe("managed repository and lifecycle", () => { } it("anchors an injected relative state root so a later chdir cannot split stack state", async () => { - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: createInMemoryManagedStackRepository(), stateRoot: "relative-managed-state", }); @@ -804,7 +836,9 @@ describe("managed repository and lifecycle", () => { it(`rejects unusable port numbers with a coded failure for the ${adapter} adapter`, async () => { const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const workspace = makeWorkspace(root); await expect( @@ -820,14 +854,14 @@ describe("managed repository and lifecycle", () => { ports: [{ key: "api.port", port: 70_000, intent: "exact" }], }), ).rejects.toBeInstanceOf(InvalidManagedPortError); - expect(service.inspectStack(created.stack.id)?.ports).toEqual([]); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([]); await service.close(); }); } it("persists stack configuration and reserves ports globally", async () => { const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); const first = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "first"), }); @@ -867,13 +901,13 @@ describe("managed repository and lifecycle", () => { ports: [{ key: "db.port", port: 54_322, intent: "exact" }], }), ).rejects.toBeInstanceOf(ManagedPortReservationError); - expect(service.inspectStack(second.stack.id)?.ports).toEqual([]); + expect((await service.inspectStack(second.stack.id))?.ports).toEqual([]); await service.close(); }); it("rolls back an in-memory registration when its initial port reservation conflicts", async () => { const root = makeRoot(); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "first"), configuration: { @@ -893,7 +927,7 @@ describe("managed repository and lifecycle", () => { }), ).rejects.toBeInstanceOf(ManagedPortReservationError); expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); - expect(service.listStacks()).toHaveLength(1); + expect(await service.listStacks()).toHaveLength(1); const retried = await service.provisionOrdinaryStack({ workspacePath: secondWorkspace }); expect(retried.outcome).toBe("create"); @@ -902,7 +936,7 @@ describe("managed repository and lifecycle", () => { it("requires actual runtime inspection before recovering an abandoned operation", async () => { const root = makeRoot(); - const service = makeInMemoryService(root, { isProcessAlive: () => false }); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); @@ -933,7 +967,7 @@ describe("managed repository and lifecycle", () => { expect(unknown.recovered).toEqual([]); expect(unknown.abortedStackIds).toEqual([]); expect(unknown.retained).toEqual([{ operation: claimed.operation, reason: "runtime-unknown" }]); - expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("starting"); + expect((await service.inspectStack(created.stack.id))?.lifecycle).toBe("starting"); const reconciled = await service.reconcileAbandonedOperations({ inspectRuntime: async () => "stopped", @@ -941,13 +975,13 @@ describe("managed repository and lifecycle", () => { expect(reconciled.retained).toEqual([]); expect(reconciled.abortedStackIds).toEqual([]); expect(reconciled.recovered).toHaveLength(1); - expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("stopped"); + expect((await service.inspectStack(created.stack.id))?.lifecycle).toBe("stopped"); }); it("aborts a crashed pending provision and makes the identity retryable", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makePersistentService(root, { + const service = await makePersistentService(root, { isProcessAlive: () => false, }); const pending = await prepareAbandonedStack(service, workspace, 987_650); @@ -961,7 +995,7 @@ describe("managed repository and lifecycle", () => { expect(reconciled.recovered).toEqual([]); expect(reconciled.retained).toEqual([]); expect(existsSync(pending.stack.paths.root)).toBe(false); - expect(service.listStacks()).toEqual([]); + expect(await service.listStacks()).toEqual([]); const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); expect(retried.outcome).toBe("create"); @@ -972,7 +1006,7 @@ describe("managed repository and lifecycle", () => { it("publishes a crashed pending provision when runtime inspection finds it running", async () => { const root = makeRoot(); const workspace = makeWorkspace(root); - const service = makePersistentService(root, { + const service = await makePersistentService(root, { isProcessAlive: () => false, }); const pending = await prepareAbandonedStack(service, workspace, 987_651); @@ -992,7 +1026,7 @@ describe("managed repository and lifecycle", () => { it("retains operations while their owner process is still alive", async () => { const root = makeRoot(); - const service = makeInMemoryService(root, { + const service = await makeInMemoryService(root, { isProcessAlive: (pid) => pid === 987_652, }); const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_652); @@ -1007,12 +1041,12 @@ describe("managed repository and lifecycle", () => { expect(inspected).toBe(false); expect(reconciled.retained).toEqual([{ operation: pending.operation, reason: "owner-alive" }]); - expect(service.inspectStack(pending.stack.id)?.status).toBe("pending"); + expect((await service.inspectStack(pending.stack.id))?.status).toBe("pending"); }); it("force-recovers an operation when a stale or reused PID still appears alive", async () => { const root = makeRoot(); - const service = makeInMemoryService(root, { isProcessAlive: () => true }); + const service = await makeInMemoryService(root, { isProcessAlive: () => true }); const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_652); const retained = await service.reconcileAbandonedOperations({ @@ -1029,7 +1063,7 @@ describe("managed repository and lifecycle", () => { }); expect(forced.abortedStackIds).toEqual([pending.stack.id]); expect(forced.retained).toEqual([]); - expect(service.listStacks()).toEqual([]); + expect(await service.listStacks()).toEqual([]); }); it.each([ @@ -1037,7 +1071,7 @@ describe("managed repository and lifecycle", () => { ["operation token", { stackId: crypto.randomUUID(), operationToken: "not-a-uuid" }], ])("rejects a forced recovery with an invalid %s", async (_label, force) => { const root = makeRoot(); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); let inspected = false; await expect( @@ -1054,7 +1088,7 @@ describe("managed repository and lifecycle", () => { it("scopes forced recovery to one exact operation", async () => { const root = makeRoot(); - const service = makeInMemoryService(root, { isProcessAlive: () => true }); + const service = await makeInMemoryService(root, { isProcessAlive: () => true }); const pending = await Promise.all( ["first", "target", "third"].map((name, index) => prepareAbandonedStack(service, makeWorkspace(root, name), 987_660 + index), @@ -1104,12 +1138,7 @@ describe("managed repository and lifecycle", () => { .map(({ operation }) => operation.token) .sort(), ); - expect( - service - .listStacks() - .map(({ id }) => id) - .sort(), - ).toEqual( + expect((await service.listStacks()).map(({ id }) => id).sort()).toEqual( pending .filter(({ stack }) => stack.id !== target.stack.id) .map(({ stack }) => stack.id) @@ -1119,7 +1148,7 @@ describe("managed repository and lifecycle", () => { it("reconciles repository operations that have no owner PID", async () => { const root = makeRoot(); - const service = makeInMemoryService(root, { isProcessAlive: () => true }); + const service = await makeInMemoryService(root, { isProcessAlive: () => true }); const pending = await prepareAbandonedStack(service, makeWorkspace(root)); const reconciled = await service.reconcileAbandonedOperations({ @@ -1132,7 +1161,7 @@ describe("managed repository and lifecycle", () => { it("does not reclaim data when another recovery pass adopts the pending stack", async () => { const root = makeRoot(); - const service = makePersistentService(root, { isProcessAlive: () => false }); + const service = await makePersistentService(root, { isProcessAlive: () => false }); const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_653); const dataFile = join(pending.stack.paths.data, "database"); writeFileSync(dataFile, "live data"); @@ -1154,7 +1183,7 @@ describe("managed repository and lifecycle", () => { expect(reconciled.abortedStackIds).toEqual([]); expect(reconciled.recovered).toEqual([]); expect(reconciled.skippedOperationIds).toEqual([pending.operation.token]); - expect(service.inspectStack(pending.stack.id)).toMatchObject({ + expect(await service.inspectStack(pending.stack.id)).toMatchObject({ status: "active", lifecycle: "running", }); @@ -1167,8 +1196,8 @@ describe("managed repository and lifecycle", () => { const root = makeRoot(); const service = adapter === "in-memory" - ? makeInMemoryService(root, { isProcessAlive: () => false }) - : makePersistentService(root, { isProcessAlive: () => false }); + ? await makeInMemoryService(root, { isProcessAlive: () => false }) + : await makePersistentService(root, { isProcessAlive: () => false }); let stackRoot: string | undefined; let dataFile: string | undefined; @@ -1203,7 +1232,7 @@ describe("managed repository and lifecycle", () => { expect(dataFile).toBeDefined(); expect(existsSync(stackRoot ?? "")).toBe(true); expect(readFileSync(dataFile ?? "", "utf8")).toBe("live data"); - expect(service.listStacks()).toEqual([ + expect(await service.listStacks()).toEqual([ expect.objectContaining({ status: "active", lifecycle: "running" }), ]); await service.close(); @@ -1213,7 +1242,7 @@ describe("managed repository and lifecycle", () => { it("retains an operation when owner liveness cannot be determined", async () => { const root = makeRoot(); const livenessError = new Error("liveness unavailable"); - const service = makeInMemoryService(root, { + const service = await makeInMemoryService(root, { isProcessAlive: () => { throw livenessError; }, @@ -1236,7 +1265,7 @@ describe("managed repository and lifecycle", () => { it("retains an operation when runtime inspection fails", async () => { const root = makeRoot(); const inspectionError = new Error("runtime unavailable"); - const service = makeInMemoryService(root, { isProcessAlive: () => false }); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); const pending = await prepareAbandonedStack(service, makeWorkspace(root), 987_671); const reconciled = await service.reconcileAbandonedOperations({ @@ -1277,7 +1306,7 @@ describe("managed repository and lifecycle", () => { }, ), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: guardedRepository, stateRoot: join(root, "managed"), isProcessAlive: () => false, @@ -1300,12 +1329,12 @@ describe("managed repository and lifecycle", () => { error: expect.any(UnsafeManagedStackPathError), }, ]); - expect(service.listStacks()).toEqual([]); + expect(await service.listStacks()).toEqual([]); }); it("continues recovery when an owner finishes one operation during inspection", async () => { const root = makeRoot(); - const service = makeInMemoryService(root, { isProcessAlive: () => false }); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); const first = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "first"), }); @@ -1361,8 +1390,8 @@ describe("managed repository and lifecycle", () => { const overrides = { isProcessAlive: () => false }; const service = adapter === "in-memory" - ? makeInMemoryService(root, overrides) - : makePersistentService(root, overrides); + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); const owner = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "owner"), configuration: { @@ -1389,7 +1418,7 @@ describe("managed repository and lifecycle", () => { error: expect.any(ManagedPortReservationError), }, ]); - expect(service.inspectStack(pending.stack.id)).toMatchObject({ + expect(await service.inspectStack(pending.stack.id)).toMatchObject({ status: "pending", lifecycle: "stopped", }); @@ -1419,8 +1448,8 @@ describe("managed repository and lifecycle", () => { const overrides = { isProcessAlive: () => false }; const service = adapter === "in-memory" - ? makeInMemoryService(root, overrides) - : makePersistentService(root, overrides); + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "owner"), configuration: { @@ -1459,7 +1488,7 @@ describe("managed repository and lifecycle", () => { error: expect.any(ManagedPortReservationError), }); expect(runRepo(service.repository.listActiveOperations())).toEqual([]); - expect(service.inspectStack(blocked.stack.id)?.lifecycle).toBe("failed"); + expect((await service.inspectStack(blocked.stack.id))?.lifecycle).toBe("failed"); await expect( service.deleteStack(blocked.stack.id, { stop: async () => {} }), ).resolves.toMatchObject({ @@ -1478,7 +1507,7 @@ describe("managed repository and lifecycle", () => { throw new Error(`Fixture ${changedFixtureId} has no persisted assignment`); } const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), configuration: { @@ -1516,7 +1545,7 @@ describe("managed repository and lifecycle", () => { throw new Error(`Fixture ${fixtureId} has no persisted assignment`); } const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), configuration: { @@ -1528,7 +1557,7 @@ describe("managed repository and lifecycle", () => { await expect( service.updateStack(created.stack.id, { ports: [requested] }), ).rejects.toBeInstanceOf(ManagedRunningStackPortChangeError); - expect(service.inspectStack(created.stack.id)?.ports).toEqual([ + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([ { key: previous.key, port: previous.port, intent: previous.intent }, ]); await service.close(); @@ -1538,7 +1567,9 @@ describe("managed repository and lifecycle", () => { it(`allows failed-stack recovery and intent-only updates with ${adapter}`, async () => { const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const failed = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "failed"), configuration: { @@ -1590,7 +1621,7 @@ describe("managed repository and lifecycle", () => { } expect(stickyAssignment.port).toBe(collisionAssignment.port); const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); const assignment = { key: stickyAssignment.key, port: stickyAssignment.port, @@ -1620,7 +1651,7 @@ describe("managed repository and lifecycle", () => { it("reports duplicate ports inside one stack as a managed reservation error", async () => { const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); @@ -1634,13 +1665,13 @@ describe("managed repository and lifecycle", () => { ], }), ).rejects.toBeInstanceOf(ManagedPortReservationError); - expect(service.inspectStack(created.stack.id)?.ports).toEqual([]); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([]); await service.close(); }); it("rejects a second operation claim without mutating the stack", async () => { const root = makeRoot(); - const service = makeInMemoryService(root); + const service = await makeInMemoryService(root); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); @@ -1660,14 +1691,16 @@ describe("managed repository and lifecycle", () => { await expect( service.updateStack(created.stack.id, { lifecycle: "running" }), ).rejects.toBeInstanceOf(ManagedOperationInProgressError); - expect(service.inspectStack(created.stack.id)?.lifecycle).toBe("stopped"); + expect((await service.inspectStack(created.stack.id))?.lifecycle).toBe("stopped"); }); for (const adapter of ["in-memory", "bun-sqlite"] as const) { it(`reports missing stacks and operation ownership mismatches with ${adapter}`, async () => { const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); await expect( service.updateStack(crypto.randomUUID(), { lifecycle: "stopped" }), @@ -1708,7 +1741,9 @@ describe("managed repository and lifecycle", () => { const reserved = { key: "api.port", port: 55_461, intent: "exact" } as const; const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const deleted = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "deleted"), configuration: { lifecycle: "running", ports: [reserved] }, @@ -1719,7 +1754,7 @@ describe("managed repository and lifecycle", () => { service.updateStack(deleted.stack.id, { lifecycle: "running", ports: [reserved] }), ).rejects.toBeInstanceOf(ManagedStackNotFoundError); - expect(service.inspectStack(deleted.stack.id)).toMatchObject({ + expect(await service.inspectStack(deleted.stack.id)).toMatchObject({ status: "tombstoned", lifecycle: "stopped", ports: [], @@ -1747,8 +1782,8 @@ describe("managed repository and lifecycle", () => { const overrides = { isProcessAlive: () => false }; const service = adapter === "in-memory" - ? makeInMemoryService(root, overrides) - : makePersistentService(root, overrides); + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); @@ -1784,7 +1819,7 @@ describe("managed repository and lifecycle", () => { expect(reconciled.retained).toEqual([]); expect(runRepo(service.repository.listActiveOperations())).toEqual([]); // The tombstone itself survives: idempotent deletion depends on it. - expect(service.inspectStack(created.stack.id)).toMatchObject({ + expect(await service.inspectStack(created.stack.id)).toMatchObject({ status: "tombstoned", lifecycle: "stopped", ports: [], @@ -1803,7 +1838,7 @@ describe("managed repository and lifecycle", () => { skippedOperationIds: [], failures: [], }); - expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); + expect((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); await expect(service.deleteStack(created.stack.id)).resolves.toMatchObject({ outcome: "no-op", }); @@ -1822,8 +1857,8 @@ describe("managed repository and lifecycle", () => { const overrides = { isProcessAlive: () => false }; const service = adapter === "in-memory" - ? makeInMemoryService(root, overrides) - : makePersistentService(root, overrides); + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); @@ -1859,7 +1894,7 @@ describe("managed repository and lifecycle", () => { expect(reconciled.failures).toEqual([]); expect(runRepo(service.repository.listActiveOperations())).toEqual([]); expect(existsSync(created.stack.paths.root)).toBe(false); - expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); + expect((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); await service.close(); }); } @@ -1872,7 +1907,7 @@ describe("managed repository and lifecycle", () => { mkdirSync(outsideRoot, { recursive: true }); writeFileSync(join(outsideRoot, "preserve"), "safe"); const registry = - adapter === "in-memory" ? undefined : openRegistry(managedRegistryPath(stateRoot)); + adapter === "in-memory" ? undefined : await openRegistry(managedRegistryPath(stateRoot)); const repository = registry?.repository ?? createInMemoryManagedStackRepository(); let forgePath = false; const guardedRepository: ManagedStackRepositoryShape = { @@ -1892,7 +1927,7 @@ describe("managed repository and lifecycle", () => { }, ), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: guardedRepository, stateRoot, isProcessAlive: () => false, @@ -1949,7 +1984,9 @@ describe("managed repository and lifecycle", () => { // would leak into one adapter's records and not the other's. const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const studio = { key: "studio.port", port: 55_501, intent: "exact" } as const; const api = { key: "api.port", port: 55_502, intent: "exact" } as const; const db = { key: "db.port", port: 55_503, intent: "exact" } as const; @@ -1961,12 +1998,12 @@ describe("managed repository and lifecycle", () => { }); expect(created.stack.ports).toEqual(sorted); - expect(service.inspectStack(created.stack.id)?.ports).toEqual(sorted); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual(sorted); const updated = await service.updateStack(created.stack.id, { ports: [db, studio, api] }); expect(updated.ports).toEqual(sorted); - expect(service.inspectStack(created.stack.id)?.ports).toEqual(sorted); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual(sorted); await service.close(); }); } @@ -1981,8 +2018,8 @@ describe("managed repository and lifecycle", () => { const overrides = { clock: () => new Date("2026-08-11T00:00:00.000Z") }; const service = adapter === "in-memory" - ? makeInMemoryService(root, overrides) - : makePersistentService(root, overrides); + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); const nextToken = descendingIdFactory(); const tokens: Array = []; for (const name of ["first", "second", "third"]) { @@ -2020,7 +2057,9 @@ describe("managed repository and lifecycle", () => { // repository is the boundary that must never store one. const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root, "claimed"), }); @@ -2043,7 +2082,7 @@ describe("managed repository and lifecycle", () => { await expect( prepareAbandonedStack(service, makeWorkspace(root, "prepared"), 0), ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); - expect(service.listStacks()).toHaveLength(1); + expect(await service.listStacks()).toHaveLength(1); await service.close(); }); } @@ -2055,7 +2094,9 @@ describe("managed repository and lifecycle", () => { // reader can see a port-occupying lease. const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const pending = await prepareAbandonedStack(service, makeWorkspace(root), process.pid); expect(() => @@ -2069,7 +2110,7 @@ describe("managed repository and lifecycle", () => { ), ).toThrow(ManagedPendingStackUpdateError); - expect(service.inspectStack(pending.stack.id)).toMatchObject({ + expect(await service.inspectStack(pending.stack.id)).toMatchObject({ status: "pending", lifecycle: "stopped", }); @@ -2081,7 +2122,9 @@ describe("managed repository and lifecycle", () => { it(`refuses to delete a running stack without a stop path with ${adapter}`, async () => { const root = makeRoot(); const service = - adapter === "in-memory" ? makeInMemoryService(root) : makePersistentService(root); + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), configuration: { lifecycle: "running" }, @@ -2091,7 +2134,7 @@ describe("managed repository and lifecycle", () => { ManagedStackNotStoppedError, ); - expect(service.inspectStack(created.stack.id)).toMatchObject({ + expect(await service.inspectStack(created.stack.id)).toMatchObject({ status: "active", lifecycle: "running", }); @@ -2142,7 +2185,7 @@ describe("managed repository and lifecycle", () => { return repository.claimOperation(input); }), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: racingRepository, stateRoot: join(root, "managed"), }); @@ -2158,7 +2201,7 @@ describe("managed repository and lifecycle", () => { }); expect(stoppedLifecycle).toBe("running"); - expect(service.inspectStack(created.stack.id)?.status).toBe("tombstoned"); + expect((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); }); it("treats a delete as successful when a concurrent forced recovery already resolved its operation", async () => { @@ -2174,7 +2217,7 @@ describe("managed repository and lifecycle", () => { ? Effect.fail(new ManagedOperationOwnershipError({ stackId })) : repository.finishOperation(stackId, operationToken, outcome, now, error), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: racingRepository, stateRoot: join(root, "managed"), }); @@ -2195,7 +2238,7 @@ describe("managed repository and lifecycle", () => { it("stops, tombstones, and reclaims one opaque stack ID idempotently", async () => { const contract = fixture("reclamation.delete-repeat-is-idempotent"); const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), configuration: { lifecycle: "running" }, @@ -2218,8 +2261,8 @@ describe("managed repository and lifecycle", () => { expect(existsSync(created.stack.paths.root)).toBe(false); expect(repeated.outcome).toBe(contract.expected.outcome); expect(repeated.dataReclamation).toEqual({ outcome: "removed" }); - expect(service.listStacks()).toEqual([]); - expect(service.listStacks({ includeTombstoned: true })).toHaveLength(1); + expect(await service.listStacks()).toEqual([]); + expect(await service.listStacks({ includeTombstoned: true })).toHaveLength(1); await service.close(); }); @@ -2247,7 +2290,7 @@ describe("managed repository and lifecycle", () => { }, ), }; - const service = makeManagedStackService({ + const service = await makeManagedStackService({ repository: guardedRepository, stateRoot: join(root, "managed"), }); @@ -2272,7 +2315,7 @@ describe("managed repository and lifecycle", () => { it("prunes checkout location metadata without touching stack data", async () => { const contract = fixture("reclamation.prune-removes-metadata-only"); const root = makeRoot(); - const service = makePersistentService(root); + const service = await makePersistentService(root); const created = await service.provisionOrdinaryStack({ workspacePath: makeWorkspace(root), }); @@ -2284,7 +2327,7 @@ describe("managed repository and lifecycle", () => { expect(contract.expected.outcome).toBe("update"); expect(pruned).toBe(1); expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); - expect(service.inspectStack(created.stack.id)?.status).toBe("active"); + expect((await service.inspectStack(created.stack.id))?.status).toBe("active"); expect(readFileSync(dataFile, "utf8")).toBe("preserve me"); await service.close(); }); @@ -2304,7 +2347,7 @@ describe("managed repository and lifecycle", () => { const runRepo = Effect.runSync; const stateRoot = ${JSON.stringify(stateRoot)}; const workspacePath = ${JSON.stringify(workspace)}; - const firstService = createManagedStackService({ stateRoot }); + const firstService = await createManagedStackService({ stateRoot }); assert.equal(runRepo(firstService.repository.getStack(randomUUID())), undefined); const first = await firstService.provisionOrdinaryStack({ workspacePath, @@ -2328,7 +2371,7 @@ describe("managed repository and lifecycle", () => { assert.equal(recovery.recovered.length, 1); assert.equal(recovery.failures.length, 0); await firstService.close(); - const secondService = createManagedStackService({ stateRoot }); + const secondService = await createManagedStackService({ stateRoot }); const second = await secondService.provisionOrdinaryStack({ workspacePath }); assert.equal(first.outcome, "create"); assert.equal(second.outcome, "reuse"); @@ -2390,7 +2433,7 @@ describe("managed repository and lifecycle", () => { } from ${JSON.stringify(entrypointUrl)}; const layer = bunSqliteManagedStackRepositoryLayer(${JSON.stringify(databasePath)}); const runtime = ManagedRuntime.make(layer); - const context = Effect.runSync(runtime.contextEffect); + const context = await runtime.context(); Effect.runSync(Context.get(context, ManagedStackRepository).listStacks()); await runtime.dispose(); `; @@ -2406,35 +2449,42 @@ describe("managed repository and lifecycle", () => { ); expect(results).toEqual(Array.from({ length: 8 }, () => ({ exitCode: 0, stderr: "" }))); - const registry = openRegistry(databasePath); + const registry = await openRegistry(databasePath); expect(runRepo(registry.repository.listStacks())).toEqual([]); await registry.close(); }); - it("fails safely when a registry has a newer schema version", () => { + it("fails safely when a registry has a newer schema version", async () => { const root = makeRoot(); const databasePath = join(root, "future.sqlite3"); const database = new Database(databasePath, { create: true }); database.exec("PRAGMA user_version = 999"); database.close(); - expect(() => openRegistry(databasePath)).toThrow(UnsupportedManagedRegistryVersionError); + await expect(openRegistry(databasePath)).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); }); - it.each([1, 2])("fails clearly instead of opening obsolete development schema v%i", (version) => { - const root = makeRoot(); - const databasePath = join(root, `obsolete-v${version}.sqlite3`); - const database = new Database(databasePath, { create: true }); - database.exec(`PRAGMA user_version = ${version}`); - database.close(); + it.each([1, 2])( + "fails clearly instead of opening obsolete development schema v%i", + async (version) => { + const root = makeRoot(); + const databasePath = join(root, `obsolete-v${version}.sqlite3`); + const database = new Database(databasePath, { create: true }); + database.exec(`PRAGMA user_version = ${version}`); + database.close(); - expect(() => openRegistry(databasePath)).toThrow(UnsupportedManagedRegistryVersionError); - }); + await expect(openRegistry(databasePath)).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); + }, + ); it("writes the current schema version into a fresh registry", async () => { const root = makeRoot(); const databasePath = managedRegistryPath(join(root, "fresh")); - await openRegistry(databasePath).close(); + await (await openRegistry(databasePath)).close(); const database = new Database(databasePath, { readonly: true }); expect(database.query("PRAGMA user_version").get()).toEqual({ diff --git a/packages/stack/src/managed/create-service.ts b/packages/stack/src/managed/create-service.ts index 8f5e31dc59..9766f72fb1 100644 --- a/packages/stack/src/managed/create-service.ts +++ b/packages/stack/src/managed/create-service.ts @@ -64,18 +64,23 @@ export type ReconcileAbandonedOperationsRequest = { /** * The managed registry as a Promise API. * - * `inspectStack` and `listStacks` stay synchronous accessors: the registry is a - * synchronous handle, and callers use them inline while deciding what to do next. + * Every method is Promise-returning, reads included: the registry lives in a + * file this process may have to wait for, so a handle that answered reads + * synchronously would only be hiding that I/O from its caller. The handle is an + * `AsyncDisposable`, so a block that acquires one with `await using` closes it + * on every path out. */ -export interface ManagedStackServiceHandle { +export interface ManagedStackServiceHandle extends AsyncDisposable { readonly stateRoot: string; readonly repository: ManagedStackRepositoryShape; provisionOrdinaryStack( options: ProvisionOrdinaryStackRequest, ): Promise; inspectOrdinaryWorkspace(workspacePath: string): Promise; - inspectStack(stackId: string): ManagedStackRecord | undefined; - listStacks(options?: { readonly includeTombstoned?: boolean }): ReadonlyArray; + inspectStack(stackId: string): Promise; + listStacks(options?: { + readonly includeTombstoned?: boolean; + }): Promise>; updateStack( stackId: string, configuration: ManagedStackConfiguration, @@ -106,15 +111,16 @@ const fromCallback = (run: () => A | Promise): Effect.Effect = : Effect.succeed(answer), ); -const managedStackServiceHandle = ( +const managedStackServiceHandle = async ( layer: Layer.Layer, -): ManagedStackServiceHandle => { +): Promise => { const runtime = ManagedRuntime.make(layer); - // Built eagerly and synchronously: the registry is a synchronous handle, the - // facade exposes synchronous reads over it, and a registry this process cannot - // open must fail while the service is being created rather than at whichever - // call happens to touch it first. - const context = Effect.runSync(runtime.contextEffect); + // Acquiring the service is the I/O it always was: the registry file is opened + // and its schema read, and a cold start may wait out another process' WAL + // conversion. Awaiting it here keeps that failure at the acquisition — a + // registry this process cannot open rejects rather than surfacing at whichever + // later call happens to touch it first — without blocking the event loop. + const context = await runtime.context(); const service = Context.get(context, ManagedStackService); const repository = Context.get(context, ManagedStackRepository); @@ -138,8 +144,8 @@ const managedStackServiceHandle = ( }, inspectOrdinaryWorkspace: (workspacePath) => runtime.runPromise(service.inspectOrdinaryWorkspace(workspacePath)), - inspectStack: (stackId) => runtime.runSync(service.inspectStack(stackId)), - listStacks: (options) => runtime.runSync(service.listStacks(options)), + inspectStack: (stackId) => runtime.runPromise(service.inspectStack(stackId)), + listStacks: (options) => runtime.runPromise(service.listStacks(options)), updateStack: (stackId, configuration) => runtime.runPromise(service.updateStack(stackId, configuration)), deleteStack: (stackId, options) => { @@ -166,6 +172,7 @@ const managedStackServiceHandle = ( service.pruneCheckoutLocations((location) => fromCallback(() => shouldPrune(location))), ), close: () => runtime.dispose(), + [Symbol.asyncDispose]: () => runtime.dispose(), }; }; @@ -191,12 +198,14 @@ const serviceLayer = ( * * The state root and owner pid are validated here, before any layer is built, so * a caller that supplied neither a usable root nor a usable pid learns about it - * from the call that made the mistake. + * from the call that made the mistake. Acquisition is asynchronous throughout, so + * that — like every other way this can fail — arrives as a rejection rather than + * as a throw the caller has to guard separately. */ -export const makeManagedStackServiceWith = ( +export const makeManagedStackServiceWith = async ( fileSystemLayer: Layer.Layer, options: MakeManagedStackServiceOptions, -): ManagedStackServiceHandle => { +): Promise => { const stateRoot = requireExplicitManagedStateRoot(options.stateRoot); assertManagedOwnerPid(options.ownerPid); return managedStackServiceHandle( @@ -215,13 +224,13 @@ export const makeManagedStackServiceWith = ( * Node entries structurally impossible, and lets the Bun test suite cover the * plumbing that the Node entry (which imports `node:sqlite`) shares. */ -export const createManagedStackServiceWith = ( +export const createManagedStackServiceWith = async ( fileSystemLayer: Layer.Layer, openRepository: ( registryPath: string, ) => Layer.Layer, options: CreateManagedStackServiceOptions, -): ManagedStackServiceHandle => { +): Promise => { const stateRoot = resolveManagedStateRoot(options); assertManagedOwnerPid(options.ownerPid); const repository = options.repository; diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index b707be2550..bcd4a54653 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -1,6 +1,6 @@ import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; import { dirname } from "node:path"; -import { Effect, Exit, Layer, Schema, Scope } from "effect"; +import { Duration, Effect, Exit, Layer, Schedule, Schema, Scope } from "effect"; import { DuplicateManagedIdentityError, InvalidManagedOwnerPidError, @@ -186,23 +186,41 @@ const isSqliteBusy = (error: unknown): boolean => { return error instanceof Error && /database is (?:busy|locked)/i.test(error.message); }; -const synchronousWait = (milliseconds: number): void => { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); -}; +const WAL_CONVERSION_RETRY_MS = 10; +const WAL_CONVERSION_RETRY_CEILING_MS = 100; +const WAL_CONVERSION_BUDGET_MS = 4_000; -const enableWriteAheadLogging = (database: ManagedSqliteDatabase): void => { - for (let attempt = 0; ; attempt += 1) { - try { +/** + * Converting a fresh registry to WAL can lose a race with another process doing + * the same thing, and SQLite reports that as a busy error instead of waiting it + * out under `busy_timeout`. The conversion is therefore retried on a schedule: + * tight at first, capped so a long contention window is not polled every 10 ms, + * and bounded by a total budget. The retry is a schedule rather than a blocking + * wait, so a cold start under contention suspends the fiber instead of stalling + * the event loop that is driving every other caller of this process. + */ +const walConversionSchedule = Schedule.exponential(Duration.millis(WAL_CONVERSION_RETRY_MS)).pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed( + Duration.millis(Math.min(Duration.toMillis(duration), WAL_CONVERSION_RETRY_CEILING_MS)), + ), + ), + Schedule.upTo({ duration: Duration.millis(WAL_CONVERSION_BUDGET_MS) }), +); + +const enableWriteAheadLogging = (database: ManagedSqliteDatabase): Effect.Effect => + Effect.try({ + try: () => { database.exec("PRAGMA journal_mode = WAL"); - return; - } catch (error: unknown) { - if (!isSqliteBusy(error) || attempt >= 49) { - throw error; - } - synchronousWait(Math.min(10 + attempt * 5, 100)); - } - } -}; + }, + catch: (error: unknown) => error, + }).pipe( + Effect.retry({ while: isSqliteBusy, schedule: walConversionSchedule }), + // Contention that never clears within the budget is not a managed failure a + // caller could recover from, so the driver's own error stays a defect — + // exactly as an immediate non-busy failure of this pragma always has. + Effect.orDie, + ); const rollbackPreservingCause = (database: ManagedSqliteDatabase): void => { try { @@ -212,10 +230,7 @@ const rollbackPreservingCause = (database: ManagedSqliteDatabase): void => { } }; -const initializeSchema = (database: ManagedSqliteDatabase): void => { - database.exec("PRAGMA busy_timeout = 5000"); - database.exec("PRAGMA foreign_keys = ON"); - enableWriteAheadLogging(database); +const migrateSchema = (database: ManagedSqliteDatabase): void => { database.exec("BEGIN IMMEDIATE"); try { const versionRow = database.prepare("PRAGMA user_version").get(); @@ -315,6 +330,34 @@ const initializeSchema = (database: ManagedSqliteDatabase): void => { } }; +/** + * Prepares a freshly opened handle for use as the registry. + * + * `busy_timeout` is set first so every later statement waits out a writer on its + * own, then the file is converted to WAL, and only then is the schema read and + * created. A registry written by an unsupported version is the one outcome a + * caller can act on, so it is the only failure this reports; everything else the + * driver raises stays a defect. + */ +const initializeRegistry = ( + database: ManagedSqliteDatabase, +): Effect.Effect => + Effect.gen(function* () { + yield* Effect.sync(() => { + database.exec("PRAGMA busy_timeout = 5000"); + database.exec("PRAGMA foreign_keys = ON"); + }); + yield* enableWriteAheadLogging(database); + yield* Effect.try({ + try: () => { + migrateSchema(database); + }, + catch: failsWith( + UnsupportedManagedRegistryVersionError, + ), + }); + }); + const commitPreservingCause = (database: ManagedSqliteDatabase): void => { try { database.exec("COMMIT"); @@ -970,14 +1013,7 @@ const createSqliteManagedStackRepository = ( database: ManagedSqliteDatabase, ): Effect.Effect => Effect.gen(function* () { - yield* Effect.try({ - try: () => { - initializeSchema(database); - }, - catch: failsWith( - UnsupportedManagedRegistryVersionError, - ), - }); + yield* initializeRegistry(database); return { prepareOrdinaryStack: (input) => @@ -1094,6 +1130,10 @@ export const hardenManagedRegistryFile = (path: string): void => { * The registry as a scoped layer: the handle is opened when the layer is built * and closed when its scope closes, including when schema initialization refuses * the registry, so no failure path can leak an open database. + * + * Building this layer is I/O and may suspend: a cold start racing another + * process' WAL conversion waits on a schedule before trying again, so the layer + * must be built through a runner that can suspend rather than `Effect.runSync`. */ export const sqliteManagedStackRepositoryLayer = ( openDatabase: () => ManagedSqliteDatabase, From 94957f6b124cf8bdcfd525aa35a7278159feeb8d Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 14:40:02 +0200 Subject: [PATCH 17/18] fix(stack): restore transactional atomicity and interruption safety in the managed core - run SQLite transactions as single synchronous blocks immune to fiber preemption - make provisioning and deletion compensation uninterruptible and interrupt-transparent - bridge thenable callbacks, surface layer errors honestly, and export the composed layer Co-Authored-By: Claude Fable 5 --- packages/stack/docs/architecture.md | 88 +++++--- packages/stack/src/entrypoints.unit.test.ts | 2 + packages/stack/src/managed-bun.ts | 11 + .../src/managed-effect.integration.test.ts | 162 ++++++++++---- packages/stack/src/managed-node.ts | 11 + .../src/managed-service.integration.test.ts | 98 ++++++++- packages/stack/src/managed.ts | 1 + packages/stack/src/managed/callback.ts | 33 +++ packages/stack/src/managed/create-service.ts | 140 ++++++++---- packages/stack/src/managed/failure.ts | 7 + packages/stack/src/managed/identity.ts | 51 +++-- packages/stack/src/managed/service.ts | 208 +++++++++++------- packages/stack/src/managed/sqlite.ts | 81 ++++--- 13 files changed, 644 insertions(+), 249 deletions(-) create mode 100644 packages/stack/src/managed/callback.ts diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index deb2d596f6..c44640474d 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -310,6 +310,12 @@ For an ordinary non-Git folder, the first mutating managed operation atomically contextId ``` +That marker protocol is the one place in the managed surface that uses raw `node:fs/promises` instead +of the `FileSystem` service the policy layer reclaims stack state through: writing a temporary file, +hardlinking it into place, re-reading the winning marker on `EEXIST`, and removing the temporary path +is a single indivisible claim, and the hardlink with that `EEXIST` contract is not part of the platform +service's surface. + No mutable runtime state or credential value is stored in that marker. Read-only discovery does not create it. The registry stores only an opaque credential reference, never resolved plaintext credentials. Discovery returns the marker identity even when it has no stack records, but reports @@ -424,6 +430,14 @@ The managed surface is two `Context.Service` tags, each with layer factories: that could never be probed is refused while the layer is being built rather than at whichever call first touches a path. +`managedStackLayer(options)` — exported from `managed-bun.ts` and `managed-node.ts` — is those two +composed with the platform filesystem and the state root resolved by the one resolver that owns that +policy. It is the assembly an Effect consumer provides _and_ the one the Promise facade runs behind its +handle, so the two cannot drift apart. It fails with `ManagedStackLayerFailure`: the state-root and +owner-PID refusals above plus `UnsupportedManagedRegistryVersionError`. Nothing on that path is turned +into a defect, so the one registry failure a caller can act on — the registry was written by a newer +CLI — stays recoverable with `catchTag` instead of being unreachable behind an `orDie`. + Each method declares only the failures it can actually raise, rather than one service-wide union: `provisionOrdinaryStack` carries `ProvisionManagedStackFailure`, `updateStack` carries `UpdateManagedStackConfigurationFailure`, `deleteStack` carries `DeleteManagedStackFailure`, @@ -434,17 +448,20 @@ stack that refused to stop was not deleted. Recovery reports rather than fails: that is not a pair of managed UUIDs refuses a whole pass, so `reconcileAbandonedOperations` declares just `InvalidManagedIdentityError` and returns retained claims, skips, and failures in its result. -Registry decisions are transactions written as `Effect.acquireUseRelease`. The acquire opens -`BEGIN IMMEDIATE` (or `BEGIN` for read paths), the use runs the decision, and the release inspects -the `Exit` to commit on success and roll back on failure — so an interrupted fiber cannot leave a -transaction open, and a rollback never masks the original cause. The decision itself stays a -synchronous closure: the drivers are synchronous, and a partially applied decision must never be -observable. - -The database handle's lifetime is a scope. `sqliteManagedStackRepositoryLayer` opens the file when -the layer is built and registers a finalizer that closes it, including on the path where schema -initialization refuses the registry, so no failure path leaks an open handle. Closing the scope that -built the layer closes the registry. +Registry decisions are transactions that run as one synchronous block: `Effect.try` wraps a closure +that issues `BEGIN IMMEDIATE` (or `BEGIN` for read paths), runs the decision, and commits, rolling +back and rethrowing the original cause if any statement refuses. Atomicity rests on the drivers being +synchronous and the handle being single-threaded, so that boundary must never be split across +effects: the fiber scheduler preempts at its operation budget, and a fiber parked between `BEGIN` and +`COMMIT` would let another fiber `BEGIN IMMEDIATE` on the same connection — SQLite refuses the nested +transaction, and either fiber's `COMMIT` could publish the other's writes. Keeping the whole +transaction in one JavaScript turn is therefore what makes a partially applied decision +unobservable and keeps interruption from ever landing inside a transaction. + +The database handle's lifetime is a scope. `sqliteManagedStackRepositoryLayer` acquires the handle +with `Effect.acquireRelease`, so opening the file and registering its close are one step nothing can +land between, including on the path where schema initialization refuses the registry: no failure path +leaks an open handle. Closing the scope that built the layer closes the registry. Waiting for a concurrent publisher is `Schedule`-driven. One look at the pending row is a retryable step — a still-pending row asks for another look, while a vanished or tombstoned row is a final @@ -452,19 +469,32 @@ answer — repeated on `Schedule.exponential` from `publicationPollMs` with a 25 publisher is not polled hundreds of times per second for the whole window. The ceiling only ever slows polling down, so a caller asking for a slower interval keeps its own. `publicationTimeoutMs` is the caller's bound on the entire wait and is applied as a timeout around the repeat, so it interrupts -the poll instead of being checked between polls. - -An Effect consumer uses the tags directly, which is the primary API: +the poll instead of being checked between polls. Both shipped adapters answer synchronously, so a look +at the pending row always completes; with an embedder-supplied asynchronous repository that timeout can +preempt a look that is still in flight. That is safe — a look has no side effects — but it means the +option bounds the wait, not the number of looks that finish. + +Interruption is part of the contract, not an afterthought. Provisioning owns a pending row, an +operation claim, and the directories it created, so its create path runs under +`Effect.uninterruptibleMask`: only the provisioning steps themselves are interruptible, and the +compensation that aborts the pending row and removes the leaked directory always runs. Deletion +releases its claim the same way. An interrupted call stays interrupted rather than being reported as a +failure of the work — a caller's own timeout is not a `ManagedStackInitializationError` — and recovery +re-raises interruption instead of recording a retained claim or a reconciliation failure that never +happened, so the operation the next pass should still recover does not look like one recovery already +gave up on. + +An Effect consumer provides the composed layer, which is the primary API: ```typescript -import { BunFileSystem } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; -import { bunSqliteManagedStackRepositoryLayer, ManagedStackService } from "@supabase/stack/managed"; +import { Effect } from "effect"; +import { managedStackLayer, ManagedStackService } from "@supabase/stack/managed"; -const managedLayer = ManagedStackService.make({ stateRoot }).pipe( - Layer.provide(bunSqliteManagedStackRepositoryLayer(registryPath)), - Layer.provide(BunFileSystem.layer), -); +// The policy service, the registry adapter it decides over, and the platform +// filesystem it reclaims stack state through. It fails with +// `ManagedStackLayerFailure`, so a registry written by a newer CLI is a typed +// failure an embedder can recover from rather than a defect. +const managedLayer = managedStackLayer({ stateRoot }); const program = Effect.gen(function* () { const managed = yield* ManagedStackService; @@ -477,7 +507,7 @@ const program = Effect.gen(function* () { ``` `createManagedStackService()` — and `makeManagedStackService()` over a repository the caller already -has — is a thin `ManagedRuntime` edge over exactly those layers, for consumers that do not run an +has — is a thin `ManagedRuntime` edge over exactly that layer, for consumers that do not run an Effect runtime. It exists to serve the Promise-oriented `createStack()` boundary; the runtime lifecycle beneath it is Effect-based either way. Three properties of that edge are contracts rather than incidental: @@ -501,10 +531,16 @@ than incidental: Because the retry suspends the fiber instead of spinning on `Atomics.wait`, a process opening the registry no longer stalls the event loop that every other caller in it depends on. -`close()` disposes the `ManagedRuntime`, which closes the scope that owns the database handle. The -handle is also an `AsyncDisposable`, so `await using service = await createManagedStackService()` -closes it on every path out of the block. The facade hands back the very repository the service uses, -so an embedder can read the registry without opening a second handle on it. +`close()` disposes the `ManagedRuntime`, which interrupts whatever is still in flight and closes the +scope that owns the database handle. Outstanding calls therefore reject, and because that scope closes +alongside those interruptions rather than after them, a statement already on its way to the driver can +race the close and fail against a closed handle: a caller that closes while work is outstanding must +read those rejections as "did not complete", not as evidence about the registry. A call made after +`close()` rejects with an `Error` saying the handle is closed, rather than with the runtime's own bare +internal string. The handle is also an `AsyncDisposable`, so +`await using service = await createManagedStackService()` closes it on every path out of the block. The +facade hands back the very repository the service uses, so an embedder can read the registry without +opening a second handle on it. ## Legacy daemon paths diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 69ed5de94b..b52f061e56 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -65,6 +65,7 @@ describe("@supabase/stack entrypoints", () => { expect(managed).toHaveProperty("createManagedStackService"); expect(managed).toHaveProperty("makeManagedStackService"); expect(managed).toHaveProperty("ManagedStackService"); + expect(managed).toHaveProperty("managedStackLayer"); expect(managed).toHaveProperty("bunSqliteManagedStackRepositoryLayer"); expect(nodeRoot).not.toHaveProperty("createManagedStackService"); }); @@ -107,6 +108,7 @@ describe("@supabase/stack entrypoints", () => { "isManagedStackError", "makeManagedStackService", "managedRegistryPath", + "managedStackLayer", "managedStackPaths", "ordinaryWorkspaceIdentityPath", "readOrdinaryWorkspaceIdentity", diff --git a/packages/stack/src/managed-bun.ts b/packages/stack/src/managed-bun.ts index 7932fad8d4..36e22d5220 100644 --- a/packages/stack/src/managed-bun.ts +++ b/packages/stack/src/managed-bun.ts @@ -1,16 +1,27 @@ +import type { Layer } from "effect"; import { BunFileSystem } from "@effect/platform-bun"; import { createManagedStackServiceWith, makeManagedStackServiceWith, + managedStackLayerWith, type CreateManagedStackServiceOptions, type MakeManagedStackServiceOptions, + type ManagedStackLayerFailure, type ManagedStackServiceHandle, } from "./managed/create-service.ts"; +import type { ManagedStackRepository } from "./managed/repository.ts"; +import type { ManagedStackService } from "./managed/service.ts"; import { bunSqliteManagedStackRepositoryLayer } from "./managed/sqlite-bun.ts"; export * from "./managed.ts"; export { bunSqliteManagedStackRepositoryLayer }; +/** The managed assembly an Effect consumer provides, bound to the Bun runtime. */ +export const managedStackLayer = ( + options: CreateManagedStackServiceOptions = {}, +): Layer.Layer => + managedStackLayerWith(BunFileSystem.layer, bunSqliteManagedStackRepositoryLayer, options); + export const createManagedStackService = ( options: CreateManagedStackServiceOptions = {}, ): Promise => diff --git a/packages/stack/src/managed-effect.integration.test.ts b/packages/stack/src/managed-effect.integration.test.ts index 098aea97b7..434228f5aa 100644 --- a/packages/stack/src/managed-effect.integration.test.ts +++ b/packages/stack/src/managed-effect.integration.test.ts @@ -1,20 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunFileSystem } from "@effect/platform-bun"; -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach } from "vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Cause, Duration, Effect, Exit } from "effect"; import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; import { + ManagedStackInitializationError, ManagedStackPublicationTimeoutError, - type UnsupportedManagedRegistryVersionError, } from "./managed/model.ts"; import { managedRegistryPath, managedStackPaths } from "./managed/paths.ts"; import { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; -import { ManagedStackRepository } from "./managed/repository.ts"; -import { ManagedStackService, type ManagedStackServiceOptions } from "./managed/service.ts"; -import { bunSqliteManagedStackRepositoryLayer } from "./managed/sqlite-bun.ts"; +import { ManagedStackRepository, type ManagedStackRepositoryShape } from "./managed/repository.ts"; +import { ManagedStackService } from "./managed/service.ts"; +import { managedStackLayer, type CreateManagedStackServiceOptions } from "./managed-bun.ts"; /** * The Effect surface of the managed registry, exercised as an Effect consumer @@ -46,22 +45,16 @@ const makeWorkspace = (root: string, name = "workspace"): string => { return workspace; }; -type ServiceOverrides = Omit; +type ServiceOverrides = Omit; /** - * The layer an Effect consumer assembles: the policy service over a repository - * adapter over the platform filesystem. The repository is merged rather than only - * provided so a test can drive the registry directly to stage a scenario. + * The layer an Effect consumer provides — the composed one the package exports, + * not a private re-assembly of it, so this suite fails if that assembly drifts. + * The repository is part of it, so a test can drive the registry directly to + * stage a scenario. */ -const managedLayer = ( - stateRoot: string, - repositoryLayer: Layer.Layer, - overrides: ServiceOverrides, -) => - ManagedStackService.make({ stateRoot, publicationPollMs: 1, ...overrides }).pipe( - Layer.provideMerge(repositoryLayer), - Layer.provide(BunFileSystem.layer), - ); +const managedLayer = (stateRoot: string, overrides: ServiceOverrides) => + managedStackLayer({ stateRoot, publicationPollMs: 1, ...overrides }); const setupInMemory = (overrides: ServiceOverrides = {}) => { const root = makeRoot(); @@ -70,11 +63,10 @@ const setupInMemory = (overrides: ServiceOverrides = {}) => { root, stateRoot, workspace: makeWorkspace(root), - layer: managedLayer( - stateRoot, - Layer.succeed(ManagedStackRepository, createInMemoryManagedStackRepository()), - overrides, - ), + layer: managedLayer(stateRoot, { + repository: createInMemoryManagedStackRepository(), + ...overrides, + }), }; }; @@ -86,12 +78,7 @@ const setupSqlite = (overrides: ServiceOverrides = {}) => { stateRoot, workspace: makeWorkspace(root), /** A fresh handle on the same registry file, the way a second process opens it. */ - openRegistry: () => - managedLayer( - stateRoot, - bunSqliteManagedStackRepositoryLayer(managedRegistryPath(stateRoot)), - overrides, - ), + openRegistry: () => managedLayer(stateRoot, overrides), }; }; @@ -244,31 +231,128 @@ describe("managed stack Effect surface", () => { }).pipe(Effect.provide(layer)); }); - it.effect("keeps a stack visible to a registry handle opened after the first one closed", () => { + // `it.live` rather than `it.effect`: this is the one test that drives the real + // SQLite adapter, whose cold start waits out another process' WAL conversion on + // a schedule. Under `TestClock` such a wait would never be released and the test + // would hang instead of failing. + it.live("keeps a stack visible to a registry handle opened after the first one closed", () => { const { workspace, stateRoot, openRegistry } = setupSqlite(); return Effect.gen(function* () { - // The registry handle belongs to the layer's scope, so each `Effect.scoped` - // block opens the file, uses it, and closes it before the next block runs. + // The registry handle belongs to the layer's scope, which `Effect.provide` + // owns, so each block opens the file, uses it, and closes it before the + // next block runs. const provisioned = yield* Effect.gen(function* () { const managed = yield* ManagedStackService; const repository = yield* ManagedStackRepository; const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); expect(yield* repository.getStack(stack.id)).toMatchObject({ id: stack.id }); return stack; - }).pipe(Effect.scoped, Effect.provide(openRegistry())); + }).pipe(Effect.provide(openRegistry())); expect(existsSync(managedRegistryPath(stateRoot))).toBe(true); const reopened = yield* Effect.gen(function* () { const managed = yield* ManagedStackService; return yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); - }).pipe(Effect.scoped, Effect.provide(openRegistry())); + }).pipe(Effect.provide(openRegistry())); expect(reopened.outcome).toBe("reuse"); expect(reopened.stack.id).toBe(provisioned.id); }); }); + it.live("rolls a provision back when the caller interrupts it mid-initialization", () => { + // A caller that times out or closes the service while initialization is + // running still owns the pending row, the operation claim, and the stack + // directory the provision created, so the compensation has to run even + // though the fiber it belongs to is being interrupted. The interruption + // itself must stay an interruption: a provision this caller abandoned is + // not an initialization that failed. + const { workspace, stateRoot, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const repository = yield* ManagedStackRepository; + + const exit = yield* managed + .provisionOrdinaryStack({ + workspacePath: workspace, + initialize: () => Effect.sleep(Duration.seconds(5)), + }) + .pipe(Effect.timeout(Duration.millis(50)), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + expect(failure).not.toBeInstanceOf(ManagedStackInitializationError); + expect(yield* repository.listStacks({ includeTombstoned: true })).toEqual([]); + expect(yield* repository.listActiveOperations()).toEqual([]); + const stackRoots = join(stateRoot, "stacks"); + expect(existsSync(stackRoots) ? readdirSync(stackRoots) : []).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("releases the delete claim when the caller interrupts a stop that never returns", () => { + const { workspace, layer } = setupInMemory(); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const repository = yield* ManagedStackRepository; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + yield* managed.updateStack(stack.id, { lifecycle: "running" }); + + const exit = yield* managed + .deleteStack(stack.id, { stop: () => Effect.sleep(Duration.seconds(5)) }) + .pipe(Effect.timeout(Duration.millis(50)), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + // The claim is gone, so the next caller can delete the stack instead of + // being refused by an operation nobody will ever finish. + expect(yield* repository.listActiveOperations()).toEqual([]); + expect((yield* managed.inspectStack(stack.id))?.status).toBe("active"); + }).pipe(Effect.provide(layer)); + }); + + it.live("propagates an interrupted recovery pass instead of recording it as a failure", () => { + // Recovery reports rather than fails, but an interrupted step has no outcome + // to report: recording one would mark a stack failed and release a claim on + // behalf of a caller that is no longer there, and the operation the next pass + // should still recover would look like one recovery already gave up on. + const root = makeRoot(); + const stateRoot = join(root, "managed"); + const workspace = makeWorkspace(root); + const repository = createInMemoryManagedStackRepository(); + // An embedder-supplied repository may be asynchronous, and a call into one + // can be cancelled: the step then reports interruption rather than a refusal. + const cancelling: ManagedStackRepositoryShape = { + ...repository, + reconcileOperation: () => Effect.interrupt, + }; + const layer = managedLayer(stateRoot, { repository: cancelling }); + return Effect.gen(function* () { + const managed = yield* ManagedStackService; + const { stack } = yield* managed.provisionOrdinaryStack({ workspacePath: workspace }); + // An abandoned claim with no owner to probe, so recovery goes straight to + // reconciling it. + const claimed = yield* repository.claimOperation({ + token: crypto.randomUUID(), + stackId: stack.id, + kind: "start", + now: "2026-08-11T00:00:00.000Z", + }); + if (!claimed.acquired) { + return yield* Effect.die(new Error("Expected to stage an abandoned operation")); + } + + const exit = yield* managed + .reconcileAbandonedOperations({ inspectRuntime: () => Effect.succeed("stopped") }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true); + expect((yield* managed.inspectStack(stack.id))?.lifecycle).not.toBe("failed"); + expect( + (yield* repository.listActiveOperations()).map((operation) => operation.token), + ).toEqual([claimed.operation.token]); + }).pipe(Effect.provide(layer)); + }); + it.live("gives up on a pending stack whose publisher never publishes", () => { // Deliberately `it.live` with a tiny window rather than `TestClock`. // `TestClock.adjust` only releases sleeps that are already registered, and @@ -302,11 +386,7 @@ describe("managed stack Effect surface", () => { // A blank root would anchor every managed path to the process' working // directory, so the layer must fail while it is being built rather than at // whichever call first touches a path. - const layer = managedLayer( - "", - Layer.succeed(ManagedStackRepository, createInMemoryManagedStackRepository()), - {}, - ); + const layer = managedLayer("", { repository: createInMemoryManagedStackRepository() }); return Effect.gen(function* () { const exit = yield* Effect.gen(function* () { return yield* ManagedStackService; diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts index c4efee9d2b..d0b5545a9b 100644 --- a/packages/stack/src/managed-node.ts +++ b/packages/stack/src/managed-node.ts @@ -1,16 +1,27 @@ +import type { Layer } from "effect"; import { NodeFileSystem } from "@effect/platform-node"; import { createManagedStackServiceWith, makeManagedStackServiceWith, + managedStackLayerWith, type CreateManagedStackServiceOptions, type MakeManagedStackServiceOptions, + type ManagedStackLayerFailure, type ManagedStackServiceHandle, } from "./managed/create-service.ts"; +import type { ManagedStackRepository } from "./managed/repository.ts"; +import type { ManagedStackService } from "./managed/service.ts"; import { nodeSqliteManagedStackRepositoryLayer } from "./managed/sqlite-node.ts"; export * from "./managed.ts"; export { nodeSqliteManagedStackRepositoryLayer }; +/** The managed assembly an Effect consumer provides, bound to the Node runtime. */ +export const managedStackLayer = ( + options: CreateManagedStackServiceOptions = {}, +): Layer.Layer => + managedStackLayerWith(NodeFileSystem.layer, nodeSqliteManagedStackRepositoryLayer, options); + export const createManagedStackService = ( options: CreateManagedStackServiceOptions = {}, ): Promise => diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index 4a769ce906..f48491106e 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from "node:os"; import { delimiter, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { Context, Effect, ManagedRuntime } from "effect"; +import { Cause, Context, Effect, Exit, ManagedRuntime } from "effect"; import { managedStackContractFixtures } from "./managed-stack-contract.ts"; import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; import { @@ -738,6 +738,54 @@ describe("managed service options", () => { await service.close(); }); + it("awaits an initialize callback that answers with a thenable rather than a Promise", async () => { + // A caller whose promises come from another implementation — a bundled + // polyfill, a Bluebird-style library — answers with a thenable that is not + // `instanceof Promise`. Publishing on such an answer would mean publishing a + // stack whose initialization has not run yet. + const root = makeRoot(); + const service = await makePersistentService(root); + let initialized = false; + // Answering `then` through a proxy rather than declaring the property: the + // lint rule that guards against accidental thenables forbids writing one, + // and being a thenable on purpose is this fixture's whole point. + const thenable = new Proxy( + {}, + { + get: (_target, property) => + property === "then" + ? (resolve: (value: undefined) => void) => { + setTimeout(() => { + initialized = true; + resolve(undefined); + }, 5); + } + : undefined, + }, + ) as unknown as Promise; + + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + initialize: () => thenable, + }); + + expect(initialized).toBe(true); + expect(created.stack.status).toBe("active"); + await service.close(); + }); + + it("rejects a call made after close with an error that says the handle is closed", async () => { + // A caller that reaches for a closed handle — a stray promise, a shutdown + // race — must get a diagnosable rejection rather than the runtime's bare + // internal string, which has neither a name nor a stack. + const root = makeRoot(); + const service = await makePersistentService(root); + await service.close(); + + await expect(service.listStacks()).rejects.toBeInstanceOf(Error); + await expect(service.listStacks()).rejects.toThrow(/closed/i); + }); + it("closes a service acquired with await using when its block ends", async () => { const root = makeRoot(); let acquired: ManagedStackServiceHandle | undefined; @@ -2466,6 +2514,22 @@ describe("managed repository and lifecycle", () => { ); }); + it("refuses the production entrypoint over a registry written by a newer CLI", async () => { + // The one registry failure a caller can act on has to survive the whole + // production path — layer, runtime, facade — as itself, so an embedder can + // tell "upgrade your CLI" apart from a bug in this one. + const root = makeRoot(); + const stateRoot = join(root, "managed"); + mkdirSync(stateRoot, { recursive: true }); + const database = new Database(managedRegistryPath(stateRoot), { create: true }); + database.exec("PRAGMA user_version = 999"); + database.close(); + + await expect(createManagedStackService({ stateRoot })).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); + }); + it.each([1, 2])( "fails clearly instead of opening obsolete development schema v%i", async (version) => { @@ -2481,6 +2545,38 @@ describe("managed repository and lifecycle", () => { }, ); + it("keeps registry transactions atomic while concurrent fibers share one handle", async () => { + // A registry decision is a transaction on a single connection, so its + // `BEGIN`, statements, and `COMMIT` must run without a suspension point + // between them: a fiber parked mid-transaction would let another fiber's + // `BEGIN IMMEDIATE` nest on the same handle, and either fiber's `COMMIT` + // could then publish the other's writes. Each fiber runs far more + // sequential decisions than the scheduler's operation budget, so it is + // preempted many times over the course of the pass. + const root = makeRoot(); + const registry = await openRegistry(managedRegistryPath(join(root, "concurrent"))); + const rounds = Array.from({ length: 2_000 }, (_, index) => index); + const hammerRegistry = Effect.forEach( + rounds, + () => + // A read transaction and a write transaction, so neither boundary is + // covered by the other's locking. + Effect.flatMap(registry.repository.listStacks(), () => + registry.repository.pruneCheckoutLocations([]), + ), + { discard: true }, + ); + + const exit = await Effect.runPromiseExit( + Effect.all([hammerRegistry, hammerRegistry, hammerRegistry, hammerRegistry], { + concurrency: "unbounded", + }), + ); + + expect(Exit.isSuccess(exit) ? "committed" : Cause.pretty(exit.cause)).toBe("committed"); + await registry.close(); + }); + it("writes the current schema version into a fresh registry", async () => { const root = makeRoot(); const databasePath = managedRegistryPath(join(root, "fresh")); diff --git a/packages/stack/src/managed.ts b/packages/stack/src/managed.ts index c10a8ca4e4..80dc7e6446 100644 --- a/packages/stack/src/managed.ts +++ b/packages/stack/src/managed.ts @@ -25,6 +25,7 @@ export type { export type { CreateManagedStackServiceOptions, MakeManagedStackServiceOptions, + ManagedStackLayerFailure, ManagedStackServiceHandle, ProvisionOrdinaryStackRequest, ReconcileAbandonedOperationsRequest, diff --git a/packages/stack/src/managed/callback.ts b/packages/stack/src/managed/callback.ts new file mode 100644 index 0000000000..20431ed174 --- /dev/null +++ b/packages/stack/src/managed/callback.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; + +/** + * The bridge every caller-supplied callback crosses on its way into the managed + * service. + * + * A callback may answer synchronously, asynchronously, or by throwing either + * way, and whatever it does becomes this effect's outcome unchanged: the + * service's handling of a refused callback is the same as it was when the + * service awaited these callbacks directly. + * + * `isAnswer` recognizes the callback's synchronous answer, and everything else + * is awaited. Testing for the synchronous shape rather than for a `Promise` is + * what makes an answer from another promise implementation — a thenable that is + * not `instanceof Promise` — awaited instead of being mistaken for work that has + * already finished. + */ +export const fromCallback = ( + run: () => A | PromiseLike, + isAnswer: (answer: A | PromiseLike) => answer is A, +): Effect.Effect => + Effect.flatMap(Effect.try({ try: run, catch: (error: unknown) => error }), (answer) => + isAnswer(answer) + ? Effect.succeed(answer) + : Effect.tryPromise({ try: () => answer, catch: (error: unknown) => error }), + ); + +/** A callback that answers by finishing, so anything else is still pending. */ +export const isFinished = (answer: void | PromiseLike): answer is void => + answer === undefined; + +export const isBooleanAnswer = (answer: boolean | PromiseLike): answer is boolean => + typeof answer === "boolean"; diff --git a/packages/stack/src/managed/create-service.ts b/packages/stack/src/managed/create-service.ts index 9766f72fb1..fbb110bcf4 100644 --- a/packages/stack/src/managed/create-service.ts +++ b/packages/stack/src/managed/create-service.ts @@ -1,11 +1,15 @@ import { Context, Effect, Layer, ManagedRuntime, type FileSystem } from "effect"; +import { fromCallback, isBooleanAnswer, isFinished } from "./callback.ts"; +import { UnsafeManagedStackPathError } from "./model.ts"; import type { + InvalidManagedOwnerPidError, ManagedCheckoutLocation, ManagedOperationRecord, ManagedStackConfiguration, ManagedStackRecord, UnsupportedManagedRegistryVersionError, } from "./model.ts"; +import { failsWith } from "./failure.ts"; import { managedRegistryPath, requireExplicitManagedStateRoot, @@ -98,18 +102,11 @@ export interface ManagedStackServiceHandle extends AsyncDisposable { close(): Promise; } -/** - * A caller-supplied callback may answer synchronously, asynchronously, or by - * throwing either way. Whatever it does becomes this effect's outcome unchanged, - * so the service's own handling of a failed callback is the same as it was when - * the service awaited promises directly. - */ -const fromCallback = (run: () => A | Promise): Effect.Effect => - Effect.flatMap(Effect.try({ try: run, catch: (error: unknown) => error }), (answer) => - answer instanceof Promise - ? Effect.tryPromise({ try: () => answer, catch: (error: unknown) => error }) - : Effect.succeed(answer), - ); +type InspectedManagedRuntime = "running" | "stopped" | "unknown"; + +const isInspectedRuntime = ( + answer: InspectedManagedRuntime | PromiseLike, +): answer is InspectedManagedRuntime => typeof answer === "string"; const managedStackServiceHandle = async ( layer: Layer.Layer, @@ -124,42 +121,60 @@ const managedStackServiceHandle = async ( const service = Context.get(context, ManagedStackService); const repository = Context.get(context, ManagedStackRepository); + /** + * Every method's run, so a call that arrives after `close` is reported as one. + * + * A disposed `ManagedRuntime` answers by dying with a bare string, which would + * reach the caller as a rejection with no name, message, or stack. Anything + * else is the failure itself and passes through untouched. + */ + const run = (effect: Effect.Effect): Promise => + runtime.runPromise(effect).catch((error: unknown) => { + throw typeof error === "string" && error.includes("disposed") + ? new Error(`The managed stack service handle is closed (${error})`) + : error; + }); + return { stateRoot: service.stateRoot, repository, provisionOrdinaryStack: (options) => { const initialize = options.initialize; const validate = options.validate; - return runtime.runPromise( + return run( service.provisionOrdinaryStack({ workspacePath: options.workspacePath, stackName: options.stackName, configuration: options.configuration, initialize: - initialize === undefined ? undefined : (stack) => fromCallback(() => initialize(stack)), + initialize === undefined + ? undefined + : (stack) => fromCallback(() => initialize(stack), isFinished), validate: - validate === undefined ? undefined : (stack) => fromCallback(() => validate(stack)), + validate === undefined + ? undefined + : (stack) => fromCallback(() => validate(stack), isFinished), }), ); }, inspectOrdinaryWorkspace: (workspacePath) => - runtime.runPromise(service.inspectOrdinaryWorkspace(workspacePath)), - inspectStack: (stackId) => runtime.runPromise(service.inspectStack(stackId)), - listStacks: (options) => runtime.runPromise(service.listStacks(options)), - updateStack: (stackId, configuration) => - runtime.runPromise(service.updateStack(stackId, configuration)), + run(service.inspectOrdinaryWorkspace(workspacePath)), + inspectStack: (stackId) => run(service.inspectStack(stackId)), + listStacks: (options) => run(service.listStacks(options)), + updateStack: (stackId, configuration) => run(service.updateStack(stackId, configuration)), deleteStack: (stackId, options) => { const stop = options?.stop; - return runtime.runPromise( + return run( service.deleteStack(stackId, { - stop: stop === undefined ? undefined : (stack) => fromCallback(() => stop(stack)), + stop: + stop === undefined ? undefined : (stack) => fromCallback(() => stop(stack), isFinished), }), ); }, reconcileAbandonedOperations: (options) => { const inspectRuntime = (stack: ManagedStackRecord, operation: ManagedOperationRecord) => - fromCallback(() => options.inspectRuntime(stack, operation)); - return runtime.runPromise( + fromCallback(() => options.inspectRuntime(stack, operation), isInspectedRuntime); + return run( service.reconcileAbandonedOperations( options.force === undefined ? { inspectRuntime, startedBefore: options.startedBefore } @@ -168,29 +183,81 @@ const managedStackServiceHandle = async ( ); }, pruneCheckoutLocations: (shouldPrune) => - runtime.runPromise( - service.pruneCheckoutLocations((location) => fromCallback(() => shouldPrune(location))), + run( + service.pruneCheckoutLocations((location) => + fromCallback(() => shouldPrune(location), isBooleanAnswer), + ), ), close: () => runtime.dispose(), [Symbol.asyncDispose]: () => runtime.dispose(), }; }; +/** + * What building a managed stack layer can refuse. + * + * {@link UnsupportedManagedRegistryVersionError} is the one an embedder can act + * on — the registry on disk was written by a newer CLI — so it stays in the error + * channel rather than being turned into a defect: an Effect consumer must be able + * to `catchTag` it. The other two are option bugs the layer refuses to start + * with. + */ +export type ManagedStackLayerFailure = + | InvalidManagedOwnerPidError + | UnsafeManagedStackPathError + | UnsupportedManagedRegistryVersionError; + const serviceLayer = ( options: ManagedStackServiceOptions, repositoryLayer: Layer.Layer, fileSystemLayer: Layer.Layer, -): Layer.Layer< - ManagedStackRepository | ManagedStackService, - UnsupportedManagedRegistryVersionError -> => +): Layer.Layer => ManagedStackService.make(options).pipe( // Merged rather than only provided: the facade hands the very repository the // service uses back to its caller, so an embedder can read the registry // without opening a second handle on it. Layer.provideMerge(repositoryLayer), Layer.provide(fileSystemLayer), - Layer.orDie, + ); + +/** + * The whole managed assembly as one layer: the policy service, the registry + * adapter it decides over, and the platform filesystem it reclaims stack state + * through, with the state root resolved by the one resolver that owns that + * policy. + * + * This is what an Effect consumer provides, and it is what the Promise facade + * runs behind its handle, so the two assemblies cannot drift apart. A caller that + * brought its own repository gets that repository instead of an opened registry + * file. + */ +export const managedStackLayerWith = ( + fileSystemLayer: Layer.Layer, + openRepository: ( + registryPath: string, + ) => Layer.Layer, + options: CreateManagedStackServiceOptions, +): Layer.Layer => + Layer.unwrap( + Effect.map( + // Resolved while the layer is built rather than while it is described, so + // an unusable root refuses the build instead of throwing at whichever + // expression happened to assemble the layer. + Effect.try({ + try: () => resolveManagedStateRoot(options), + catch: failsWith(UnsafeManagedStackPathError), + }), + (stateRoot) => { + const repository = options.repository; + return serviceLayer( + { ...options, stateRoot }, + repository === undefined + ? openRepository(managedRegistryPath(stateRoot)) + : Layer.succeed(ManagedStackRepository, repository), + fileSystemLayer, + ); + }, + ), ); /** @@ -231,16 +298,11 @@ export const createManagedStackServiceWith = async ( ) => Layer.Layer, options: CreateManagedStackServiceOptions, ): Promise => { + // Validated here as well as in the layer, so a caller that supplied an + // unusable root or pid learns about it from the call that made the mistake. const stateRoot = resolveManagedStateRoot(options); assertManagedOwnerPid(options.ownerPid); - const repository = options.repository; return managedStackServiceHandle( - serviceLayer( - { ...options, stateRoot }, - repository === undefined - ? openRepository(managedRegistryPath(stateRoot)) - : Layer.succeed(ManagedStackRepository, repository), - fileSystemLayer, - ), + managedStackLayerWith(fileSystemLayer, openRepository, { ...options, stateRoot }), ); }; diff --git a/packages/stack/src/managed/failure.ts b/packages/stack/src/managed/failure.ts index 453854533b..44af9d9485 100644 --- a/packages/stack/src/managed/failure.ts +++ b/packages/stack/src/managed/failure.ts @@ -9,6 +9,13 @@ * that throws as a defect, so a corrupt registry row or a decoder bug stays a * defect instead of widening a method's error channel to `unknown`. * + * Both handlers here are therefore for `Effect.try` only. `Effect.tryPromise` + * calls its `catch` handler from inside the promise chain the runtime is + * awaiting, so a handler that rethrows there escapes into that chain instead of + * becoming a defect. An asynchronous call sorts its failures after the fact + * instead — see `identity.ts`, which recovers the effect with `Effect.catch` and + * dies on anything it does not recognize. + * * The expected union must be named explicitly, because TypeScript infers a * single class from a variadic list of unrelated constructors instead of * unioning them: diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index baa65b58ec..8e9313472d 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -9,7 +9,6 @@ import { } from "./model.ts"; import { assertManagedUuid, createManagedUuid } from "./ids.ts"; import { errorCode } from "./error-code.ts"; -import { failsWith } from "./failure.ts"; import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; /** @@ -17,8 +16,22 @@ import { ordinaryWorkspaceIdentityPath } from "./paths.ts"; * errors that are not part of the identity protocol — an unreadable workspace, a * full disk — are defects: no caller can act on them, and inventing an identity * failure for them would hide what actually went wrong. + * + * Every protocol step here is a promise, so the sorting happens after the effect + * fails rather than inside `tryPromise`'s `catch` handler: `Effect.try` turns a + * throwing handler into a defect, but a `tryPromise` handler that throws does so + * inside the promise chain the runtime is awaiting, where nothing is watching for + * it. */ -const failsWithIdentity = failsWith(InvalidManagedIdentityError); +const failsWithIdentity = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.catch(effect, (error) => + error instanceof InvalidManagedIdentityError ? Effect.fail(error) : Effect.die(error), + ); + +/** A `catch` handler that classifies nothing, so it can never throw. */ +const asRaised = (error: unknown): unknown => error; const identityField = (value: unknown, field: string): string => { if (typeof value !== "object" || value === null) { @@ -64,16 +77,18 @@ const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { export const canonicalizeOrdinaryWorkspacePath = ( workspacePath: string, ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - const info = await stat(workspacePath); - if (!info.isDirectory()) { - throw new InvalidManagedIdentityError({ message: `${workspacePath} is not a directory` }); - } - return realpath(workspacePath); - }, - catch: failsWithIdentity, - }); + failsWithIdentity( + Effect.tryPromise({ + try: async () => { + const info = await stat(workspacePath); + if (!info.isDirectory()) { + throw new InvalidManagedIdentityError({ message: `${workspacePath} is not a directory` }); + } + return realpath(workspacePath); + }, + catch: asRaised, + }), + ); const readIdentity = async ( workspacePath: string, @@ -92,7 +107,7 @@ const readIdentity = async ( export const readOrdinaryWorkspaceIdentity = ( workspacePath: string, ): Effect.Effect => - Effect.tryPromise({ try: () => readIdentity(workspacePath), catch: failsWithIdentity }); + failsWithIdentity(Effect.tryPromise({ try: () => readIdentity(workspacePath), catch: asRaised })); export interface EnsureOrdinaryWorkspaceIdentityResult { readonly identity: OrdinaryWorkspaceIdentity; @@ -151,7 +166,9 @@ export const ensureOrdinaryWorkspaceIdentity = ( workspacePath: string, idFactory: () => string = randomUUID, ): Effect.Effect => - Effect.tryPromise({ - try: () => ensureIdentity(workspacePath, idFactory), - catch: failsWithIdentity, - }); + failsWithIdentity( + Effect.tryPromise({ + try: () => ensureIdentity(workspacePath, idFactory), + catch: asRaised, + }), + ); diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts index 385e64944b..528101464a 100644 --- a/packages/stack/src/managed/service.ts +++ b/packages/stack/src/managed/service.ts @@ -43,6 +43,7 @@ import { managedStackPaths, requireExplicitManagedStateRoot, } from "./paths.ts"; +import { fromCallback, isBooleanAnswer } from "./callback.ts"; import { errorCode } from "./error-code.ts"; import { failsWith } from "./failure.ts"; import { @@ -261,6 +262,23 @@ const dataRetained = (error: unknown): DeleteManagedStackResult["dataReclamation const unregisteredWorkspace: InspectOrdinaryWorkspaceResult = { registered: false, stacks: [] }; +/** + * How recovery and best-effort cleanup absorb a step that refused. + * + * Whatever the registry, the filesystem, or a caller's seam raised becomes part + * of the report — that is what makes these paths best-effort — but an interrupted + * step has no outcome to report at all: recording one would invent a refusal that + * never happened, mark a stack failed on behalf of a caller that has gone away, + * and make the operation the next pass should still recover look like one + * recovery already gave up on. So interruption is re-raised instead. + */ +const recordUnlessInterrupted = + (record: (cause: Cause.Cause) => Effect.Effect) => + (self: Effect.Effect): Effect.Effect => + Effect.catchCause(self, (cause) => + Cause.hasInterruptsOnly(cause) ? Effect.interrupt : record(cause), + ); + /** What one look at a stack awaiting publication can refuse to wait for. */ type PublicationPollFailure = ManagedAbandonedOperationError | ManagedStackNotFoundError; @@ -353,13 +371,7 @@ export class ManagedStackService extends Context.Service< * kept in the error channel here rather than being turned into a defect. */ const probeProcessAlive = (pid: number): Effect.Effect => - Effect.flatMap( - Effect.try({ try: () => isProcessAlive(pid), catch: (error: unknown) => error }), - (answer) => - typeof answer === "boolean" - ? Effect.succeed(answer) - : Effect.tryPromise({ try: () => answer, catch: (error: unknown) => error }), - ); + fromCallback(() => isProcessAlive(pid), isBooleanAnswer); /** * A stack's directory is only ever removed through the path guard, so a @@ -381,7 +393,7 @@ export class ManagedStackService extends Context.Service< ): Effect.Effect => removeStackState(stack).pipe( Effect.as(dataRemoved), - Effect.catchCause((cause) => Effect.succeed(dataRetained(Cause.squash(cause)))), + recordUnlessInterrupted((cause) => Effect.succeed(dataRetained(Cause.squash(cause)))), ); const finishOperationBestEffort = ( @@ -392,7 +404,7 @@ export class ManagedStackService extends Context.Service< repository.finishOperation(stackId, operationToken, "failed", now(), String(error)).pipe( Effect.as(true), // Preserve the operation's original failure when ownership changed concurrently. - Effect.catchCause(() => Effect.succeed(false)), + recordUnlessInterrupted(() => Effect.succeed(false)), ); /** @@ -427,7 +439,7 @@ export class ManagedStackService extends Context.Service< }) .pipe( // Releasing the abandoned claim is still useful if the failed lifecycle cannot be recorded. - Effect.catchCause(() => Effect.void), + recordUnlessInterrupted(() => Effect.void), Effect.flatMap(() => finishOperationBestEffort(operation.stackId, operation.token, error), ), @@ -504,9 +516,12 @@ export class ManagedStackService extends Context.Service< | ManagedStackPublicationTimeoutError > => pollPublication(pending).pipe( + // A refinement, so the answer the repeat stops on is narrowed to the + // published stack. The type argument is explicit because the + // narrowing is lost when the generic guard is inferred here. Effect.repeat({ schedule: publicationPollSchedule, - while: (published) => Option.isNone(published), + while: Option.isNone, }), // The timeout is the caller's bound on the whole wait, so it // interrupts the poll rather than being checked between polls. @@ -515,11 +530,7 @@ export class ManagedStackService extends Context.Service< orElse: () => Effect.fail(new ManagedStackPublicationTimeoutError({ stackId: pending.id })), }), - Effect.flatMap((published) => - Option.isSome(published) - ? Effect.succeed(published.value) - : Effect.fail(new ManagedStackPublicationTimeoutError({ stackId: pending.id })), - ), + Effect.map((published) => published.value), ); const updateStackRecord = ( @@ -637,45 +648,61 @@ export class ManagedStackService extends Context.Service< const pending = prepared.stack; const operation = prepared.operation; - return yield* Effect.gen(function* () { - yield* fs.makeDirectory(pending.paths.data, { recursive: true, mode: 0o700 }); - yield* fs.makeDirectory(pending.paths.logs, { recursive: true, mode: 0o700 }); - yield* fs.makeDirectory(pending.paths.runtime, { recursive: true, mode: 0o700 }); - if (provisionOptions.initialize !== undefined) { - yield* provisionOptions.initialize(pending); - } - if (provisionOptions.validate !== undefined) { - yield* provisionOptions.validate(pending); - } - const published = yield* repository.publishPendingStack( - pending.id, - operation.token, - now(), - ); - return provisionResult("create", published, marker.created); - }).pipe( - Effect.catchCause((cause) => + // Between preparing the pending row and publishing it, this call + // owns a registry row, an operation claim, and the directories it + // created, so the compensation has to run even when the fiber is + // interrupted: a caller that times out or closes the service must + // not leave a pending stack and a leaked directory behind. Only the + // provisioning steps are interruptible; the rollback is not. + return yield* Effect.uninterruptibleMask((restore) => + restore( Effect.gen(function* () { - const cleanupErrors: Array = []; - const aborted = yield* Effect.exit( - repository.abortPendingStack(pending.id, operation.token), - ); - if (Exit.isFailure(aborted)) { - cleanupErrors.push(Cause.squash(aborted.cause)); - } else { - const reclaimed = yield* Effect.exit(removeStackState(pending)); - if (Exit.isFailure(reclaimed)) { - cleanupErrors.push(Cause.squash(reclaimed.cause)); - } + yield* fs.makeDirectory(pending.paths.data, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(pending.paths.logs, { recursive: true, mode: 0o700 }); + yield* fs.makeDirectory(pending.paths.runtime, { recursive: true, mode: 0o700 }); + if (provisionOptions.initialize !== undefined) { + yield* provisionOptions.initialize(pending); } - return yield* Effect.fail( - new ManagedStackInitializationError({ - stackId: pending.id, - cause: Cause.squash(cause), - cleanupErrors, - }), + if (provisionOptions.validate !== undefined) { + yield* provisionOptions.validate(pending); + } + const published = yield* repository.publishPendingStack( + pending.id, + operation.token, + now(), ); + return provisionResult("create", published, marker.created); }), + ).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const cleanupErrors: Array = []; + const aborted = yield* Effect.exit( + repository.abortPendingStack(pending.id, operation.token), + ); + if (Exit.isFailure(aborted)) { + cleanupErrors.push(Cause.squash(aborted.cause)); + } else { + const reclaimed = yield* Effect.exit(removeStackState(pending)); + if (Exit.isFailure(reclaimed)) { + cleanupErrors.push(Cause.squash(reclaimed.cause)); + } + } + // A provision the caller abandoned is not an initialization + // that failed: the interruption is the outcome, and + // reporting it as a failure would tell the caller its own + // timeout was the stack's fault. + return yield* Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.fail( + new ManagedStackInitializationError({ + stackId: pending.id, + cause: Cause.squash(cause), + cleanupErrors, + }), + ); + }), + ), ), ); }); @@ -713,38 +740,51 @@ export class ManagedStackService extends Context.Service< return deletionResult("no-op", existing, yield* reclaimStackState(existing)); } const operation = yield* requireOperation(stackId, "delete"); - return yield* Effect.gen(function* () { - const current = yield* repository.getStack(stackId); - if (current === undefined) { - return yield* Effect.fail(new ManagedStackNotFoundError({ stackId })); - } - if (current.status === "tombstoned") { - const dataReclamation = yield* reclaimStackState(current); - yield* repository.finishOperation(stackId, operation.token, "completed", now()); - return deletionResult("no-op", current, dataReclamation); - } - if (current.lifecycle !== "stopped") { - const stop = deleteOptions?.stop; - if (stop === undefined) { - return yield* Effect.fail(new ManagedStackNotStoppedError({ stackId })); - } - yield* stop(current); - yield* repository.updateStack({ - stackId, - operationToken: operation.token, - now: now(), - lifecycle: "stopped", - runtimeMetadata: { processIds: {}, containerIds: {} }, - }); - } - const tombstoned = yield* repository.tombstoneStack(stackId, operation.token, now()); - const dataReclamation = yield* reclaimStackState(tombstoned); - yield* finishDeleteOperationTolerantly(stackId, operation.token); - return deletionResult("delete", tombstoned, dataReclamation); - }).pipe( - Effect.catchCause((cause) => - finishOperationBestEffort(stackId, operation.token, Cause.squash(cause)).pipe( - Effect.flatMap(() => Effect.failCause(cause)), + // The claim belongs to this call, so releasing it has to survive an + // interruption too: a caller that gave up mid-delete must not leave + // the stack claimed by an operation nobody will ever finish. The + // original cause is re-raised either way, so an interrupted delete + // stays interrupted. + return yield* Effect.uninterruptibleMask((restore) => + restore( + Effect.gen(function* () { + const current = yield* repository.getStack(stackId); + if (current === undefined) { + return yield* Effect.fail(new ManagedStackNotFoundError({ stackId })); + } + if (current.status === "tombstoned") { + const dataReclamation = yield* reclaimStackState(current); + yield* repository.finishOperation(stackId, operation.token, "completed", now()); + return deletionResult("no-op", current, dataReclamation); + } + if (current.lifecycle !== "stopped") { + const stop = deleteOptions?.stop; + if (stop === undefined) { + return yield* Effect.fail(new ManagedStackNotStoppedError({ stackId })); + } + yield* stop(current); + yield* repository.updateStack({ + stackId, + operationToken: operation.token, + now: now(), + lifecycle: "stopped", + runtimeMetadata: { processIds: {}, containerIds: {} }, + }); + } + const tombstoned = yield* repository.tombstoneStack( + stackId, + operation.token, + now(), + ); + const dataReclamation = yield* reclaimStackState(tombstoned); + yield* finishDeleteOperationTolerantly(stackId, operation.token); + return deletionResult("delete", tombstoned, dataReclamation); + }), + ).pipe( + Effect.catchCause((cause) => + finishOperationBestEffort(stackId, operation.token, Cause.squash(cause)).pipe( + Effect.flatMap(() => Effect.failCause(cause)), + ), ), ), ); @@ -863,7 +903,7 @@ export class ManagedStackService extends Context.Service< } reclaimedStackIds.push(stack.id); }).pipe( - Effect.catchCause((cause) => + recordUnlessInterrupted((cause) => Effect.gen(function* () { const error = Cause.squash(cause); if ( diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index bcd4a54653..95e6875be8 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -1,6 +1,6 @@ import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; import { dirname } from "node:path"; -import { Duration, Effect, Exit, Layer, Schedule, Schema, Scope } from "effect"; +import { Duration, Effect, Layer, Schedule, Schema } from "effect"; import { DuplicateManagedIdentityError, InvalidManagedOwnerPidError, @@ -368,53 +368,53 @@ const commitPreservingCause = (database: ManagedSqliteDatabase): void => { }; /** - * Runs one registry decision inside a transaction. + * `BEGIN`, the decision's statements, and `COMMIT` as one synchronous block. * - * The decision itself stays a synchronous closure — the drivers are synchronous, - * and a partially applied decision must never be observable — while the - * transaction boundary is an acquired resource: the `Exit` decides whether the - * statement batch commits or rolls back, so an interrupted fiber cannot leave a - * transaction open. `catchFailure` names the domain failures the decision - * raises; anything else is a defect and still rolls back. + * Atomicity here rests on the drivers being synchronous and the handle being + * single-threaded: nothing else can run between the statements, so a partially + * applied decision is never observable and two transactions can never nest on + * the same connection. That only holds while the whole block is one JavaScript + * turn — splitting the boundary across effects would reintroduce a suspension + * point where the fiber scheduler could preempt at its operation budget and let + * another fiber `BEGIN` on this very handle. + */ +const runTransaction = ( + database: ManagedSqliteDatabase, + begin: "BEGIN" | "BEGIN IMMEDIATE", + run: () => A, +): A => { + database.exec(begin); + let decided: A; + try { + decided = run(); + } catch (error: unknown) { + rollbackPreservingCause(database); + throw error; + } + commitPreservingCause(database); + return decided; +}; + +/** + * Runs one registry decision inside a transaction. `catchFailure` names the + * domain failures the decision raises; anything else is a defect, and either way + * the statement batch has already rolled back. */ const transaction = ( database: ManagedSqliteDatabase, run: () => A, catchFailure: (error: unknown) => E, ): Effect.Effect => - Effect.acquireUseRelease( - Effect.sync(() => { - database.exec("BEGIN IMMEDIATE"); - }), - () => Effect.try({ try: run, catch: catchFailure }), - (_, exit) => - Effect.sync(() => { - if (Exit.isSuccess(exit)) { - commitPreservingCause(database); - return; - } - rollbackPreservingCause(database); - }), - ); + Effect.try({ + try: () => runTransaction(database, "BEGIN IMMEDIATE", run), + catch: catchFailure, + }); const readTransaction = ( database: ManagedSqliteDatabase, run: () => A, ): Effect.Effect => - Effect.acquireUseRelease( - Effect.sync(() => { - database.exec("BEGIN"); - }), - () => Effect.try({ try: run, catch: neverFails }), - (_, exit) => - Effect.sync(() => { - if (Exit.isSuccess(exit)) { - commitPreservingCause(database); - return; - } - rollbackPreservingCause(database); - }), - ); + Effect.try({ try: () => runTransaction(database, "BEGIN", run), catch: neverFails }); const decodePort = (row: unknown): ManagedPortAssignment => ({ key: getString(row, "key"), @@ -1141,12 +1141,11 @@ export const sqliteManagedStackRepositoryLayer = ( Layer.effect( ManagedStackRepository, Effect.gen(function* () { - const scope = yield* Effect.scope; - const database = yield* Effect.sync(openDatabase); - yield* Scope.addFinalizer( - scope, + // Opening the handle and registering its close are one acquisition, so no + // interruption can land between them and leak the open database. + const database = yield* Effect.acquireRelease(Effect.sync(openDatabase), (open) => Effect.sync(() => { - database.close(); + open.close(); }), ); return yield* createSqliteManagedStackRepository(database); From b060d05441971f615d9f93abc6074cb7b2e5934f Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 12 Aug 2026 15:26:27 +0200 Subject: [PATCH 18/18] fix(stack): reject duplicate managed port keys with a coded error Co-Authored-By: Claude Fable 5 --- .../shared/telemetry/error-actionability.ts | 9 ++++- packages/stack/src/entrypoints.unit.test.ts | 1 + .../src/managed-service.integration.test.ts | 38 +++++++++++++++++++ packages/stack/src/managed/model.ts | 15 +++++++- .../stack/src/managed/repository-memory.ts | 4 ++ packages/stack/src/managed/repository.ts | 6 ++- packages/stack/src/managed/sqlite.ts | 4 ++ 7 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index a030f32265..a23106060b 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -116,6 +116,7 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "managed_pending_update", "managed_port", "managed_port_change", + "managed_port_duplicate_key", "managed_publication_timeout", "managed_recovery", "managed_stack_name", @@ -848,6 +849,12 @@ const managedActionabilityByCode: Record { expect(Object.keys(managed).sort()).toEqual([ "DEFAULT_MANAGED_STACK_NAME", "DuplicateManagedIdentityError", + "DuplicateManagedPortKeyError", "InvalidManagedIdentityError", "InvalidManagedOwnerPidError", "InvalidManagedPortError", diff --git a/packages/stack/src/managed-service.integration.test.ts b/packages/stack/src/managed-service.integration.test.ts index f48491106e..4f4cb8db13 100644 --- a/packages/stack/src/managed-service.integration.test.ts +++ b/packages/stack/src/managed-service.integration.test.ts @@ -24,6 +24,7 @@ import { } from "./managed/paths.ts"; import { DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, InvalidManagedIdentityError, MANAGED_REGISTRY_SCHEMA_VERSION, InvalidManagedOwnerPidError, @@ -907,6 +908,43 @@ describe("managed repository and lifecycle", () => { }); } + for (const adapter of ["in-memory", "bun-sqlite"] as const) { + it(`rejects duplicate port keys with a coded failure for the ${adapter} adapter`, async () => { + const root = makeRoot(); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const workspace = makeWorkspace(root); + const duplicateKeyPorts = [ + { key: "api.port", port: 54_401, intent: "automatic" as const }, + { key: "api.port", port: 54_402, intent: "automatic" as const }, + ]; + + const provisionFailure = await service + .provisionOrdinaryStack({ + workspacePath: workspace, + configuration: { ports: duplicateKeyPorts }, + }) + .catch((error: unknown) => error); + expect(provisionFailure).toBeInstanceOf(DuplicateManagedPortKeyError); + expect((provisionFailure as DuplicateManagedPortKeyError).code).toBe( + "MANAGED_DUPLICATE_PORT_KEY", + ); + + const created = await service.provisionOrdinaryStack({ workspacePath: workspace }); + const updateFailure = await service + .updateStack(created.stack.id, { ports: duplicateKeyPorts }) + .catch((error: unknown) => error); + expect(updateFailure).toBeInstanceOf(DuplicateManagedPortKeyError); + expect((updateFailure as DuplicateManagedPortKeyError).code).toBe( + "MANAGED_DUPLICATE_PORT_KEY", + ); + expect((await service.inspectStack(created.stack.id))?.ports).toEqual([]); + await service.close(); + }); + } + it("persists stack configuration and reserves ports globally", async () => { const root = makeRoot(); const service = await makePersistentService(root); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index c75851f053..9504661236 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -130,6 +130,16 @@ export class DuplicateManagedIdentityError extends Data.TaggedError( } } +export class DuplicateManagedPortKeyError extends Data.TaggedError("DuplicateManagedPortKeyError")<{ + readonly key: string; +}> { + readonly code = "MANAGED_DUPLICATE_PORT_KEY" as const; + + override get message(): string { + return `Duplicate managed port key ${this.key}`; + } +} + export class InvalidManagedStackNameError extends Data.TaggedError("InvalidManagedStackNameError")<{ readonly stackName: string; }> { @@ -315,6 +325,7 @@ export class ManagedAbandonedOperationError extends Data.TaggedError( */ export type ManagedStackError = | DuplicateManagedIdentityError + | DuplicateManagedPortKeyError | InvalidManagedIdentityError | InvalidManagedOwnerPidError | InvalidManagedPortError @@ -349,6 +360,7 @@ export type ManagedStackError = export const MANAGED_ERROR_CODES = [ "DUPLICATE_MANAGED_IDENTITY", "INVALID_MANAGED_IDENTITY", + "MANAGED_DUPLICATE_PORT_KEY", "MANAGED_INVALID_OWNER_PID", "MANAGED_INVALID_PORT", "MANAGED_INVALID_STACK_NAME", @@ -376,11 +388,12 @@ export type ManagedErrorCode = (typeof MANAGED_ERROR_CODES)[number]; * dispatch) and `code` is the stable wire-level contract. Consumers that key a * table by one and dispatch on the other — the CLI's telemetry classifier is * the motivating case — derive it from this map instead of restating all - * seventeen pairs by hand. + * eighteen pairs by hand. */ export const MANAGED_ERROR_TAG_BY_CODE = { DUPLICATE_MANAGED_IDENTITY: "DuplicateManagedIdentityError", INVALID_MANAGED_IDENTITY: "InvalidManagedIdentityError", + MANAGED_DUPLICATE_PORT_KEY: "DuplicateManagedPortKeyError", MANAGED_INVALID_OWNER_PID: "InvalidManagedOwnerPidError", MANAGED_INVALID_PORT: "InvalidManagedPortError", MANAGED_INVALID_STACK_NAME: "InvalidManagedStackNameError", diff --git a/packages/stack/src/managed/repository-memory.ts b/packages/stack/src/managed/repository-memory.ts index 6bacf331a3..4ddebecaa7 100644 --- a/packages/stack/src/managed/repository-memory.ts +++ b/packages/stack/src/managed/repository-memory.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import { DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, InvalidManagedOwnerPidError, InvalidManagedPortError, ManagedOperationOwnershipError, @@ -474,6 +475,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepositorySh try: () => prepareOrdinaryStack(input), catch: failsWith( DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, InvalidManagedOwnerPidError, InvalidManagedPortError, ManagedOperationOwnershipError, @@ -530,6 +532,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepositorySh Effect.try({ try: () => updateStack(input), catch: failsWith( + DuplicateManagedPortKeyError, InvalidManagedPortError, ManagedOperationOwnershipError, ManagedPendingStackUpdateError, @@ -559,6 +562,7 @@ export const createInMemoryManagedStackRepository = (): ManagedStackRepositorySh Effect.try({ try: () => reconcileOperation(stackId, operationToken, lifecycle, now), catch: failsWith( + DuplicateManagedPortKeyError, InvalidManagedPortError, ManagedOperationOwnershipError, ManagedPortReservationError, diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts index a8855504cd..58b8d0bfc2 100644 --- a/packages/stack/src/managed/repository.ts +++ b/packages/stack/src/managed/repository.ts @@ -1,5 +1,6 @@ import { Context, type Effect } from "effect"; import { + DuplicateManagedPortKeyError, InvalidManagedOwnerPidError, InvalidManagedPortError, ManagedPendingStackUpdateError, @@ -77,6 +78,7 @@ export type ReconcileManagedOperationResult = /** Failures both adapters raise while registering an ordinary workspace stack. */ export type PrepareOrdinaryStackFailure = | DuplicateManagedIdentityError + | DuplicateManagedPortKeyError | InvalidManagedOwnerPidError | InvalidManagedPortError | ManagedOperationOwnershipError @@ -91,6 +93,7 @@ export type ClaimManagedOperationFailure = /** Failures both adapters raise while reconfiguring a published stack. */ export type UpdateManagedStackFailure = + | DuplicateManagedPortKeyError | InvalidManagedPortError | ManagedOperationOwnershipError | ManagedPendingStackUpdateError @@ -104,6 +107,7 @@ export type UpdateManagedStackFailure = * fails the reconciliation rather than stealing the lease. */ export type ReconcileManagedOperationFailure = + | DuplicateManagedPortKeyError | InvalidManagedPortError | ManagedOperationOwnershipError | ManagedPortReservationError @@ -240,7 +244,7 @@ export const validateManagedPortAssignments = ( throw new InvalidManagedPortError({ port: assignment.port, key: assignment.key }); } if (keys.has(assignment.key)) { - throw new Error(`Duplicate managed port key ${assignment.key}`); + throw new DuplicateManagedPortKeyError({ key: assignment.key }); } if (numbers.has(assignment.port)) { throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: stackId }); diff --git a/packages/stack/src/managed/sqlite.ts b/packages/stack/src/managed/sqlite.ts index 95e6875be8..38a7856503 100644 --- a/packages/stack/src/managed/sqlite.ts +++ b/packages/stack/src/managed/sqlite.ts @@ -3,6 +3,7 @@ import { dirname } from "node:path"; import { Duration, Effect, Layer, Schedule, Schema } from "effect"; import { DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, InvalidManagedOwnerPidError, InvalidManagedPortError, MANAGED_REGISTRY_SCHEMA_VERSION, @@ -1023,6 +1024,7 @@ const createSqliteManagedStackRepository = ( () => prepareOrdinaryStack(database, input), failsWith( DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, InvalidManagedPortError, ManagedOperationOwnershipError, ManagedPortReservationError, @@ -1072,6 +1074,7 @@ const createSqliteManagedStackRepository = ( database, () => updateStack(database, input), failsWith( + DuplicateManagedPortKeyError, InvalidManagedPortError, ManagedOperationOwnershipError, ManagedPendingStackUpdateError, @@ -1087,6 +1090,7 @@ const createSqliteManagedStackRepository = ( database, () => reconcileOperation(database, stackId, operationToken, lifecycle, now), failsWith( + DuplicateManagedPortKeyError, InvalidManagedPortError, ManagedOperationOwnershipError, ManagedPortReservationError,