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..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,6 +10,7 @@ declare global { readonly glob: (patterns: ReadonlyArray) => Record Promise>; } } +import { MANAGED_ERROR_CODES, MANAGED_ERROR_TAG_BY_CODE } from "@supabase/stack/managed-model"; import { CliErrorCategory, CliErrorKind, @@ -17,6 +18,7 @@ import { ErrorActionabilityFingerprintId, ErrorActionabilityId, isClassifiedExternalErrorTag, + isClassifiedManagedErrorCode, } from "./error-actionability.ts"; /** @@ -195,6 +197,54 @@ describe("workspace package error tags have external adapters", () => { } }); +// 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_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 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()); + }); +}); + 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 6b8eced12d..a23106060b 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -1,3 +1,8 @@ +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"; @@ -102,6 +107,20 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "invalid_url", "internal_build", "invalid_config", + "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_port_duplicate_key", + "managed_publication_timeout", + "managed_recovery", + "managed_stack_name", + "managed_stack_not_stopped", "network", "not_found", "plan_limit", @@ -755,8 +774,128 @@ const effectCliActionabilityByTag = { UnrecognizedOption: () => actionability.invalidInput, } satisfies Record; +/** + * `@supabase/stack` managed registry failures, keyed by the stable `code` + * 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 + * `@supabase/stack` without being classified here. + */ +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: "managed_operation_in_progress", + }, + MANAGED_OPERATION_OWNERSHIP_MISMATCH: { + ...actionability.stopStack, + fingerprint_suffix: "managed_operation_ownership", + }, + MANAGED_STACK_PUBLICATION_TIMEOUT: { + ...actionability.stopStack, + fingerprint_suffix: "managed_publication_timeout", + }, + MANAGED_OPERATION_REQUIRES_RECONCILIATION: { + ...actionability.stopStack, + fingerprint_suffix: "managed_recovery", + }, + MANAGED_STACK_NOT_STOPPED: { + ...actionability.stopStack, + fingerprint_suffix: "managed_stack_not_stopped", + }, + MANAGED_RUNNING_STACK_PORT_CHANGE: { + ...actionability.stopStack, + fingerprint_suffix: "managed_port_change", + }, + // The operation owner pid comes from the CLI process itself, never from user + // input, so a rejected pid is a broken internal invariant. + MANAGED_INVALID_OWNER_PID: { + ...actionability.impossibleState, + fingerprint_suffix: "managed_owner_pid", + }, + // Only internal misuse of the repository can call `updateStack` on a still + // unpublished (pending) row; nothing a user does reaches this. + MANAGED_PENDING_STACK_UPDATE: { + ...actionability.impossibleState, + fingerprint_suffix: "managed_pending_update", + }, + 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" }, + // Two of the user's own port assignments name the same key, which is the + // user's configured value rather than a conflict with another stack. + MANAGED_DUPLICATE_PORT_KEY: { + ...actionability.invalidConfig, + fingerprint_suffix: "managed_port_duplicate_key", + }, + // 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", + }, +}; + +/** + * 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 + * eighteen 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]; + }), +); + +/** + * 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 classes; the tags themselves are checked + * through {@link isClassifiedExternalErrorTag}, which the generated entries + * satisfy. + */ +export function isClassifiedManagedErrorCode(code: string): boolean { + 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 @@ -1030,6 +1169,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. 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..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,6 +598,137 @@ describe("classifyCliErrorActionability", () => { expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); }); + // 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", + "INVALID_MANAGED_IDENTITY", + "managed_identity", + "invalid_input", + ], + ["InvalidManagedPortError", "MANAGED_INVALID_PORT", "managed_port", "invalid_config"], + [ + "UnsafeManagedStackPathError", + "UNSAFE_MANAGED_STACK_PATH", + "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 through its generated tag adapter", (tag, code, suffix, category) => { + const error = new Error("managed registry failure"); + 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(`tag:${tag}:${suffix}`); + }); + + 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"); + }); + + // 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, "_tag", { value: "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, "_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( + "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", + detail: "x", + cause: managed, + }); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:ManagedPortReservationError: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 6d056e4908..61255536a1 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,71 @@ const supabase = createClient(stack.url, stack.publishableKey); 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. 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"; + +await using managed = await createManagedStackService(); +const result = await managed.provisionOrdinaryStack({ + workspacePath: "/absolute/project", +}); + +console.log((await managed.inspectStack(result.stack.id))?.status); +``` + +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 +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. + ### With explicit config ```typescript diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 847f705351..c44640474d 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,278 @@ 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 + +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 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. +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. 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: + +```text +/.supabase/identity.json + version + projectId + checkoutId + contextId +``` -With the default cache root (`~/.supabase`), durable data is project-keyed: +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 +`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 +stack UUID: + +```text +/ + registry-v3.sqlite3 + stacks// + data/ + logs/ + runtime/ +``` + +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. 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 +garbage collection; remove that root only after independently confirming its runtime is stopped. + +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 +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, 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. 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 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, +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`, +`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. 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. 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. 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. + +## 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. + +`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`, +`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 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 +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. 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 { Effect } from "effect"; +import { managedStackLayer, ManagedStackService } from "@supabase/stack/managed"; + +// 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; + 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 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: + +- **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 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 + +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 +568,23 @@ 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. 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 @@ -311,6 +599,11 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l 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/package.json b/packages/stack/package.json index d8c3f695d8..7b8124105d 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -12,6 +12,11 @@ "bun": "./src/effect-bun.ts", "default": "./src/effect-node.ts" }, + "./managed": { + "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" }, @@ -56,8 +61,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..f8c6dc0a71 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,11 @@ 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", + }, + "./managed-model": "./src/managed/model.ts", "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts", }); @@ -55,6 +61,63 @@ 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("ManagedStackService"); + expect(managed).toHaveProperty("managedStackLayer"); + expect(managed).toHaveProperty("bunSqliteManagedStackRepositoryLayer"); + 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", + "DuplicateManagedPortKeyError", + "InvalidManagedIdentityError", + "InvalidManagedOwnerPidError", + "InvalidManagedPortError", + "InvalidManagedStackNameError", + "MANAGED_ERROR_CODES", + "MANAGED_ERROR_TAG_BY_CODE", + "MANAGED_REGISTRY_SCHEMA_VERSION", + "ManagedAbandonedOperationError", + "ManagedOperationInProgressError", + "ManagedOperationOwnershipError", + "ManagedPendingStackUpdateError", + "ManagedPortReservationError", + "ManagedRunningStackPortChangeError", + "ManagedStackInitializationError", + "ManagedStackNotFoundError", + "ManagedStackNotStoppedError", + "ManagedStackPublicationTimeoutError", + "ManagedStackRepository", + "ManagedStackService", + "ORDINARY_WORKSPACE_IDENTITY_VERSION", + "UnsafeManagedStackPathError", + "UnsupportedManagedRegistryVersionError", + "assertManagedStackRoot", + "assertManagedUuid", + "bunSqliteManagedStackRepositoryLayer", + "canonicalizeOrdinaryWorkspacePath", + "createManagedStackService", + "createManagedUuid", + "ensureOrdinaryWorkspaceIdentity", + "isManagedStackError", + "makeManagedStackService", + "managedRegistryPath", + "managedStackLayer", + "managedStackPaths", + "ordinaryWorkspaceIdentityPath", + "readOrdinaryWorkspaceIdentity", + "requireExplicitManagedStateRoot", + "resolveManagedStateRoot", + ]); + }); + it("binds consumer Effect layers without exposing implementation tags", () => { expectTypeOf(nodeEffect.foregroundLayer).returns.toEqualTypeOf>(); expectTypeOf(bunEffect.foregroundLayer).returns.toEqualTypeOf>(); @@ -74,6 +137,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..36e22d5220 --- /dev/null +++ b/packages/stack/src/managed-bun.ts @@ -0,0 +1,32 @@ +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 => + createManagedStackServiceWith(BunFileSystem.layer, bunSqliteManagedStackRepositoryLayer, options); + +export const makeManagedStackService = ( + options: MakeManagedStackServiceOptions, +): Promise => makeManagedStackServiceWith(BunFileSystem.layer, options); 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..434228f5aa --- /dev/null +++ b/packages/stack/src/managed-effect.integration.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from "@effect/vitest"; +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 { Cause, Duration, Effect, Exit } from "effect"; +import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; +import { + ManagedStackInitializationError, + ManagedStackPublicationTimeoutError, +} from "./managed/model.ts"; +import { managedRegistryPath, managedStackPaths } from "./managed/paths.ts"; +import { createInMemoryManagedStackRepository } from "./managed/repository-memory.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 + * 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 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, overrides: ServiceOverrides) => + managedStackLayer({ stateRoot, publicationPollMs: 1, ...overrides }); + +const setupInMemory = (overrides: ServiceOverrides = {}) => { + const root = makeRoot(); + const stateRoot = join(root, "managed"); + return { + root, + stateRoot, + workspace: makeWorkspace(root), + layer: managedLayer(stateRoot, { + repository: 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, 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.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, 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.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.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 + // 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("", { repository: 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); + }); + }); +}); 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..c104ca59e0 --- /dev/null +++ b/packages/stack/src/managed-model.unit.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import * as model 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; +} + +/** + * 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 = { + 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 Error)) return []; + const error: unknown = Reflect.construct(value, [CONSTRUCTOR_PROBE]); + if (!(error instanceof Error)) return []; + return [ + { + exportName, + error, + code: Reflect.get(error, "code"), + tag: Reflect.get(error, "_tag"), + }, + ]; + }, +); + +/** + * 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 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, 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 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-node.ts b/packages/stack/src/managed-node.ts new file mode 100644 index 0000000000..d0b5545a9b --- /dev/null +++ b/packages/stack/src/managed-node.ts @@ -0,0 +1,36 @@ +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 => + createManagedStackServiceWith( + NodeFileSystem.layer, + nodeSqliteManagedStackRepositoryLayer, + options, + ); + +export const makeManagedStackService = ( + options: MakeManagedStackServiceOptions, +): Promise => makeManagedStackServiceWith(NodeFileSystem.layer, options); 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..7d83a9bb4c --- /dev/null +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -0,0 +1,157 @@ +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"; +import { + assertManagedStackRoot, + managedStackPaths, + resolveManagedStateRoot, +} from "./managed/paths.ts"; + +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"], + ])("rejects %s managed UUIDs", (_case, value) => { + expect(() => assertManagedUuid(value, "test id")).toThrow(InvalidManagedIdentityError); + }); + + it("isolates managed records beneath SUPABASE_HOME", () => { + expect( + resolveManagedStateRoot({ + env: { SUPABASE_HOME: "/configured/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).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", + ); + 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("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("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(() => + resolveManagedStateRoot({ stateRoot, env: {}, homeDir: "/home/user", platform: "linux" }), + ).toThrow(UnsafeManagedStackPathError); + } + expect(() => + resolveManagedStateRoot({ + stateRoot: "", + env: { SUPABASE_HOME: "/configured/supabase" }, + homeDir: "/home/user", + platform: "linux", + }), + ).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", () => { + 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", + 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", + }); + }); + + 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 new file mode 100644 index 0000000000..4f4cb8db13 --- /dev/null +++ b/packages/stack/src/managed-service.integration.test.ts @@ -0,0 +1,2630 @@ +import { Database } from "bun:sqlite"; +import { + copyFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +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 { Cause, Context, Effect, Exit, ManagedRuntime } from "effect"; +import { managedStackContractFixtures } from "./managed-stack-contract.ts"; +import { ensureOrdinaryWorkspaceIdentity } from "./managed/identity.ts"; +import { + managedRegistryPath, + managedStackPaths, + ordinaryWorkspaceIdentityPath, +} from "./managed/paths.ts"; +import { + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedIdentityError, + MANAGED_REGISTRY_SCHEMA_VERSION, + InvalidManagedOwnerPidError, + ManagedAbandonedOperationError, + InvalidManagedPortError, + InvalidManagedStackNameError, + ManagedPendingStackUpdateError, + ManagedOperationInProgressError, + ManagedOperationOwnershipError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackInitializationError, + ManagedStackNotFoundError, + ManagedStackNotStoppedError, + ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, + UnsupportedManagedRegistryVersionError, + type ManagedStackConfiguration, + type ManagedStackRecord, +} from "./managed/model.ts"; +import { createInMemoryManagedStackRepository } from "./managed/repository-memory.ts"; +import { ManagedStackRepository, type ManagedStackRepositoryShape } from "./managed/repository.ts"; +import type { MakeManagedStackServiceOptions, ManagedStackServiceHandle } from "./managed-bun.ts"; +import { + bunSqliteManagedStackRepositoryLayer, + createManagedStackService, + makeManagedStackService, +} from "./managed-bun.ts"; + +/** + * 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. 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 = async ( + databasePath: string, +): Promise<{ + readonly repository: ManagedStackRepositoryShape; + readonly close: () => Promise; +}> => { + const runtime = ManagedRuntime.make(bunSqliteManagedStackRepositoryLayer(databasePath)); + return { + repository: Context.get(await runtime.context(), ManagedStackRepository), + close: () => runtime.dispose(), + }; +}; + +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 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 = {}, +): Promise => + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + publicationPollMs: 1, + ...overrides, + }); + +const makePersistentService = ( + root: string, + overrides: ServiceOverrides = {}, +): Promise => + createManagedStackService({ + stateRoot: join(root, "managed"), + publicationPollMs: 1, + ...overrides, + }); + +/** + * 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) { + throw new Error(`Missing managed stack contract fixture ${id}`); + } + 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: ManagedStackServiceHandle, + workspace: string, + ownerPid?: number, + configuration: ManagedStackConfiguration = {}, +) => { + const identity = (await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace))).identity; + const stackId = crypto.randomUUID(); + 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"); + } + mkdirSync(prepared.stack.paths.data, { recursive: true }); + return prepared; +}; + +describe("ordinary-folder managed stack contract", () => { + it("restricts registry and stack state permissions to the owning user", async () => { + const root = makeRoot(); + const service = await makePersistentService(root); + const stateRoot = join(root, "managed"); + const { stack } = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + await 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("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 = await makePersistentService(root); + await 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); + const service = await makeInMemoryService(root); + + const result = await service.inspectOrdinaryWorkspace(workspace); + + expect(result).toEqual({ registered: false, stacks: [] }); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(false); + 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 = await makeInMemoryService(root); + const marker = await Effect.runPromise(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: ManagedStackRecord | undefined; + const filteringRepository: ManagedStackRepositoryShape = { + ...repository, + listStacks: (options) => + Effect.map(repository.listStacks(options), (stacks) => + foreignContextStack === undefined ? stacks : [...stacks, foreignContextStack], + ), + }; + const service = await 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); + const service = await 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(runRepo(service.repository.listStacks())).toEqual([]); + expect(runRepo(service.repository.listCheckoutLocations())).toEqual([]); + }); + + it.each(invalidStackNameCases)("rejects %s", async (_fixtureId, stackName) => { + const root = makeRoot(); + const workspace = makeWorkspace(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(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 = await 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"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await 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, + }); + + await service.close(); + 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(await reopened.listStacks()).toHaveLength(1); + await reopened.close(); + + 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, + ); + 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 = await 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 = await 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 (runRepo(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(runRepo(service.repository.listStacks())).toHaveLength(1); + await 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 = await makePersistentService(root); + let releaseInitialization: () => void = () => {}; + const initializationGate = new Promise((resolve) => { + releaseInitialization = resolve; + }); + + const first = service.provisionOrdinaryStack({ + workspacePath: workspace, + initialize: async () => { + await initializationGate; + }, + }); + while (runRepo(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(await service.inspectStack(created.stack.id)).toMatchObject({ + ports: [requested], + serviceVersions: { postgres: "17.6.1.143" }, + }); + await service.close(); + }); + + it("rolls back failed initialization and makes the same start retryable", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await 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(await service.listStacks()).toEqual([]); + expect(existsSync(ordinaryWorkspaceIdentityPath(workspace))).toBe(true); + + const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(retried.outcome).toBe("create"); + expect(await service.listStacks()).toHaveLength(1); + await service.close(); + }); + + it("rejects a copied ordinary-folder identity claim", async () => { + const root = makeRoot(); + const firstWorkspace = makeWorkspace(root, "first"); + const secondWorkspace = makeWorkspace(root, "copy"); + const service = await 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(await service.listStacks()).toHaveLength(1); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); + await service.close(); + }); + + it("times out without adopting a pending stack owned by another caller", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await makePersistentService(root, { + publicationTimeoutMs: 2, + publicationPollMs: 1, + }); + await prepareAbandonedStack(service, workspace, process.pid); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedStackPublicationTimeoutError); + expect(await service.listStacks()).toHaveLength(1); + await 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: ManagedStackRepositoryShape = { + ...repository, + prepareOrdinaryStack: (input) => + Effect.map(repository.prepareOrdinaryStack(input), (prepared) => + prepared.outcome === "existing" && prepared.operation !== undefined + ? { ...prepared, operation: { ...prepared.operation, ownerPid } } + : prepared, + ), + }; + const service = await makeManagedStackService({ + repository: corruptedRepository, + stateRoot: join(root, "managed"), + publicationTimeoutMs: 5_000, + publicationPollMs: 1, + isProcessAlive: () => { + livenessProbes += 1; + return true; + }, + }); + await prepareAbandonedStack(service, workspace, process.pid); + + await expect( + service.provisionOrdinaryStack({ workspacePath: workspace }), + ).rejects.toBeInstanceOf(ManagedAbandonedOperationError); + + expect(livenessProbes).toBe(0); + await 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: ManagedStackRepositoryShape = { + ...repository, + getStack: (stackId) => + Effect.suspend(() => { + pollTimes.push(performance.now()); + return repository.getStack(stackId); + }), + }; + const service = await 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(2); + const gaps = pollTimes.slice(1).map((time, index) => time - (pollTimes[index] ?? 0)); + expect(gaps.slice(0, 2).every((gap) => gap >= 350)).toBe(true); + await service.close(); + }); + + it("rejects a non-UUID stack factory result before deriving state paths", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + await Effect.runPromise(ensureOrdinaryWorkspaceIdentity(workspace)); + const service = await 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(await service.listStacks()).toEqual([]); + }); +}); + +describe("managed service options", () => { + it.each([ + ["empty", ""], + ["whitespace", " "], + ["tab", "\t"], + ])( + "refuses an %s state root instead of falling back to the working directory", + async (_case, stateRoot) => { + await expect( + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot, + }), + ).rejects.toBeInstanceOf(UnsafeManagedStackPathError); + }, + ); + + 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 + // 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 { + await expect( + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: undefined, + } as unknown as MakeManagedStackServiceOptions), + ).rejects.toBeInstanceOf(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", + async (ownerPid) => { + const root = makeRoot(); + await expect( + makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid, + }), + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); + }, + ); + + it("validates owner pids on the shared entrypoint options path too", async () => { + const root = makeRoot(); + await expect( + createManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid: 0, + }), + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); + + const service = await createManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: join(root, "managed"), + ownerPid: 4321, + }); + expect(service.stateRoot).toBe(join(root, "managed")); + 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; + { + 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", () => { + 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" + ? await makeInMemoryService(root, overrides) + : await 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((await service.listStacks()).map((stack) => stack.id)).toEqual( + [first.stack.id, second.stack.id].sort(), + ); + + const paths = runRepo(service.repository.listCheckoutLocations()).map( + (location) => location.canonicalPath, + ); + expect(paths).toEqual([...paths].sort()); + expect(paths).toHaveLength(2); + await 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"); + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = + adapter === "in-memory" + ? await makeInMemoryService(root) + : await 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); + await service.close(); + }); + } + + it("anchors an injected relative state root so a later chdir cannot split stack state", async () => { + const service = await makeManagedStackService({ + repository: createInMemoryManagedStackRepository(), + stateRoot: "relative-managed-state", + }); + expect(service.stateRoot).toBe(resolve("relative-managed-state")); + await 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" + ? await makeInMemoryService(root) + : await 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((await service.inspectStack(created.stack.id))?.ports).toEqual([]); + await service.close(); + }); + } + + 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); + 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, { + lifecycle: "starting", + ports: [{ key: "db.port", port: 54_322, intent: "exact" }], + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + 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 = await makeInMemoryService(root); + await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + configuration: { + lifecycle: "running", + ports: [{ key: "api.port", port: 54_321, intent: "exact" }], + }, + }); + const secondWorkspace = makeWorkspace(root, "second"); + + await expect( + service.provisionOrdinaryStack({ + workspacePath: secondWorkspace, + configuration: { + lifecycle: "starting", + ports: [{ key: "api.port", port: 54_321, intent: "exact" }], + }, + }), + ).rejects.toBeInstanceOf(ManagedPortReservationError); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(1); + expect(await service.listStacks()).toHaveLength(1); + + const retried = await service.provisionOrdinaryStack({ workspacePath: secondWorkspace }); + expect(retried.outcome).toBe("create"); + expect(runRepo(service.repository.listCheckoutLocations())).toHaveLength(2); + }); + + it("requires actual runtime inspection before recovering an abandoned operation", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + 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"); + } + 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", + }); + expect(unknown.recovered).toEqual([]); + expect(unknown.abortedStackIds).toEqual([]); + expect(unknown.retained).toEqual([{ operation: claimed.operation, reason: "runtime-unknown" }]); + expect((await service.inspectStack(created.stack.id))?.lifecycle).toBe("starting"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async () => "stopped", + }); + expect(reconciled.retained).toEqual([]); + expect(reconciled.abortedStackIds).toEqual([]); + expect(reconciled.recovered).toHaveLength(1); + 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 = await 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(await service.listStacks()).toEqual([]); + + const retried = await service.provisionOrdinaryStack({ workspacePath: workspace }); + expect(retried.outcome).toBe("create"); + expect(retried.stack.id).not.toBe(pending.stack.id); + await service.close(); + }); + + it("publishes a crashed pending provision when runtime inspection finds it running", async () => { + const root = makeRoot(); + const workspace = makeWorkspace(root); + const service = await 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); + await service.close(); + }); + + it("retains operations while their owner process is still alive", async () => { + const root = makeRoot(); + const service = await 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([{ operation: pending.operation, reason: "owner-alive" }]); + 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 = await 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: { + stackId: pending.stack.id, + operationToken: pending.operation.token, + }, + inspectRuntime: async () => "stopped", + }); + expect(forced.abortedStackIds).toEqual([pending.stack.id]); + expect(forced.retained).toEqual([]); + expect(await 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 = await 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 = await 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(runRepo(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( + runRepo(service.repository.listActiveOperations()) + .map(({ token }) => token) + .sort(), + ).toEqual( + pending + .filter(({ stack }) => stack.id !== target.stack.id) + .map(({ operation }) => operation.token) + .sort(), + ); + expect((await 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 = await 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 = 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"); + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async (stack, operation) => { + runRepo( + 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(await service.inspectStack(pending.stack.id)).toMatchObject({ + status: "active", + lifecycle: "running", + }); + expect(readFileSync(dataFile, "utf8")).toBe("live data"); + await 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" + ? await makeInMemoryService(root, { isProcessAlive: () => false }) + : await 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 = runRepo(service.repository.listActiveOperations()).find( + (candidate) => candidate.stackId === stack.id, + ); + if (operation === undefined) { + throw new Error("Expected the provision operation to remain active"); + } + runRepo( + 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(await service.listStacks()).toEqual([ + expect.objectContaining({ status: "active", lifecycle: "running" }), + ]); + await service.close(); + }); + } + + it("retains an operation when owner liveness cannot be determined", async () => { + const root = makeRoot(); + const livenessError = new Error("liveness unavailable"); + const service = await 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 = await 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(runRepo(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: ManagedStackRepositoryShape = { + ...repository, + 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 = await 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", + }); + + // 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, + phase: "state-reclamation", + operationReleased: true, + error: expect.any(UnsafeManagedStackPathError), + }, + ]); + expect(await service.listStacks()).toEqual([]); + }); + + it("continues recovery when an owner finishes one operation during inspection", async () => { + const root = makeRoot(); + const service = await makeInMemoryService(root, { isProcessAlive: () => false }); + const first = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "first"), + }); + const second = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root, "second"), + }); + 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"); + } + + const reconciled = await service.reconcileAbandonedOperations({ + inspectRuntime: async (stack, operation) => { + if (stack.id === first.stack.id) { + runRepo( + 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]); + 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" + ? await makeInMemoryService(root, overrides) + : await 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(await service.inspectStack(pending.stack.id)).toMatchObject({ + status: "pending", + lifecycle: "stopped", + }); + expect(runRepo(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(runRepo(service.repository.listActiveOperations())).toEqual([]); + await 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(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await 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 = 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"); + } + + 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(runRepo(service.repository.listActiveOperations())).toEqual([]); + expect((await service.inspectStack(blocked.stack.id))?.lifecycle).toBe("failed"); + await expect( + service.deleteStack(blocked.stack.id, { stop: async () => {} }), + ).resolves.toMatchObject({ + outcome: "delete", + }); + await 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"; + 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 = await 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" }]); + await 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 = await 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((await service.inspectStack(created.stack.id))?.ports).toEqual([ + { key: previous.key, port: previous.port, intent: previous.intent }, + ]); + await 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" + ? await makeInMemoryService(root) + : await 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" }], + }); + await service.close(); + }); + } + + it("keeps stopped sticky assignments soft and claims them only while starting", async () => { + 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 = await makePersistentService(root); + const assignment = { + key: stickyAssignment.key, + port: stickyAssignment.port, + intent: stickyAssignment.intent, + }; + 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); + 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"); + await service.close(); + }); + + it("reports duplicate ports inside one stack as a managed reservation error", async () => { + const root = makeRoot(); + const service = await 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((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 = await makeInMemoryService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + 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"); + } + + await expect( + service.updateStack(created.stack.id, { lifecycle: "running" }), + ).rejects.toBeInstanceOf(ManagedOperationInProgressError); + 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" + ? await makeInMemoryService(root) + : await makePersistentService(root); + + await expect( + service.updateStack(crypto.randomUUID(), { lifecycle: "stopped" }), + ).rejects.toBeInstanceOf(ManagedStackNotFoundError); + + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + 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(() => + runRepo( + service.repository.finishOperation( + created.stack.id, + crypto.randomUUID(), + "completed", + "2026-08-11T00:00:01.000Z", + ), + ), + ).toThrow(ManagedOperationOwnershipError); + await service.close(); + }); + } + + 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" + ? await makeInMemoryService(root) + : await 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(await service.inspectStack(deleted.stack.id)).toMatchObject({ + status: "tombstoned", + lifecycle: "stopped", + ports: [], + }); + 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]); + await service.close(); + }); + } + + for (const adapter of ["in-memory", "bun-sqlite"] 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, + // including nothing at all. + const root = makeRoot(); + const overrides = { isProcessAlive: () => false }; + const service = + adapter === "in-memory" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + writeFileSync(join(created.stack.paths.data, "database"), "leaked"); + 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"); + } + runRepo( + 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(runRepo(service.repository.listActiveOperations())).toEqual([]); + // The tombstone itself survives: idempotent deletion depends on it. + expect(await 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((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); + await expect(service.deleteStack(created.stack.id)).resolves.toMatchObject({ + outcome: "no-op", + }); + await service.close(); + }); + } + } + + 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" + ? await makeInMemoryService(root, overrides) + : await makePersistentService(root, overrides); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + writeFileSync(join(created.stack.paths.data, "database"), "leaked"); + 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"); + } + runRepo( + 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(runRepo(service.repository.listActiveOperations())).toEqual([]); + expect(existsSync(created.stack.paths.root)).toBe(false); + expect((await service.inspectStack(created.stack.id))?.status).toBe("tombstoned"); + await 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 registry = + adapter === "in-memory" ? undefined : await openRegistry(managedRegistryPath(stateRoot)); + const repository = registry?.repository ?? createInMemoryManagedStackRepository(); + let forgePath = false; + const guardedRepository: ManagedStackRepositoryShape = { + ...repository, + 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 = await makeManagedStackService({ + repository: guardedRepository, + stateRoot, + isProcessAlive: () => false, + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + 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"); + } + runRepo( + 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"); + await service.close(); + await registry?.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" + ? 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; + 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((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((await service.inspectStack(created.stack.id))?.ports).toEqual(sorted); + await 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" + ? await makeInMemoryService(root, overrides) + : await 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 = 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"); + } + tokens.push(token); + } + + expect(tokens).toEqual([...tokens].sort().reverse()); + expect(runRepo(service.repository.listActiveOperations()).map(({ token }) => token)).toEqual( + [...tokens].sort(), + ); + await 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" + ? await makeInMemoryService(root) + : await 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(() => + runRepo( + service.repository.claimOperation({ + token: crypto.randomUUID(), + stackId: created.stack.id, + kind: "start", + ownerPid, + now: "2026-08-11T00:00:00.000Z", + }), + ), + ).toThrow(InvalidManagedOwnerPidError); + } + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + + await expect( + prepareAbandonedStack(service, makeWorkspace(root, "prepared"), 0), + ).rejects.toBeInstanceOf(InvalidManagedOwnerPidError); + expect(await service.listStacks()).toHaveLength(1); + await 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" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const pending = await prepareAbandonedStack(service, makeWorkspace(root), process.pid); + + expect(() => + runRepo( + service.repository.updateStack({ + stackId: pending.stack.id, + operationToken: pending.operation.token, + now: "2026-08-11T00:00:02.000Z", + lifecycle: "running", + }), + ), + ).toThrow(ManagedPendingStackUpdateError); + + expect(await service.inspectStack(pending.stack.id)).toMatchObject({ + status: "pending", + lifecycle: "stopped", + }); + await 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" + ? await makeInMemoryService(root) + : await makePersistentService(root); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + configuration: { lifecycle: "running" }, + }); + + await expect(service.deleteStack(created.stack.id)).rejects.toBeInstanceOf( + ManagedStackNotStoppedError, + ); + + expect(await service.inspectStack(created.stack.id)).toMatchObject({ + status: "active", + lifecycle: "running", + }); + expect(runRepo(service.repository.listActiveOperations())).toEqual([]); + await service.close(); + }); + } + + it("re-reads lifecycle after claiming delete before deciding whether to stop", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let promoteBeforeDelete = true; + const racingRepository: ManagedStackRepositoryShape = { + ...repository, + 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, + ), + ); + } + return repository.claimOperation(input); + }), + }; + const service = await 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((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 () => { + // 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: ManagedStackRepositoryShape = { + ...repository, + finishOperation: (stackId, operationToken, outcome, now, error) => + outcome === "completed" + ? Effect.fail(new ManagedOperationOwnershipError({ stackId })) + : repository.finishOperation(stackId, operationToken, outcome, now, error), + }; + const service = await 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); + await service.close(); + }); + + it("stops, tombstones, and reclaims one opaque stack ID idempotently", async () => { + const contract = fixture("reclamation.delete-repeat-is-idempotent"); + const root = makeRoot(); + const service = await 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; + }, + }); + 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"); + 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(await service.listStacks()).toEqual([]); + expect(await service.listStacks({ includeTombstoned: true })).toHaveLength(1); + await service.close(); + }); + + it("reports unsafe tombstone data as retained without deleting it", async () => { + const root = makeRoot(); + const repository = createInMemoryManagedStackRepository(); + let forgePath = false; + const outsideRoot = join(root, "outside"); + mkdirSync(outsideRoot); + writeFileSync(join(outsideRoot, "preserve"), "safe"); + const guardedRepository: ManagedStackRepositoryShape = { + ...repository, + 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 = await makeManagedStackService({ + repository: guardedRepository, + stateRoot: join(root, "managed"), + }); + const created = await service.provisionOrdinaryStack({ + workspacePath: makeWorkspace(root), + }); + await service.deleteStack(created.stack.id); + forgePath = true; + + 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"); + }); + + it("prunes checkout location metadata without touching stack data", async () => { + const contract = fixture("reclamation.prune-removes-metadata-only"); + const root = makeRoot(); + const service = await 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(runRepo(service.repository.listCheckoutLocations())).toEqual([]); + expect((await service.inspectStack(created.stack.id))?.status).toBe("active"); + expect(readFileSync(dataFile, "utf8")).toBe("preserve me"); + 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"); + // 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 { Effect } from "effect"; + import { createManagedStackService } from ${JSON.stringify(entrypointUrl)}; + const runRepo = Effect.runSync; + const stateRoot = ${JSON.stringify(stateRoot)}; + const workspacePath = ${JSON.stringify(workspace)}; + const firstService = await createManagedStackService({ stateRoot }); + assert.equal(runRepo(firstService.repository.getStack(randomUUID())), undefined); + 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 = 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); + await firstService.close(); + const secondService = await 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 = 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"); + 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"); + await 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 = managedRegistryPath(join(root, "cold")); + const entrypointUrl = pathToFileURL(join(process.cwd(), "src/managed-bun.ts")).href; + const source = ` + 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 = await runtime.context(); + 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" }), + ); + + 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 registry = await openRegistry(databasePath); + expect(runRepo(registry.repository.listStacks())).toEqual([]); + await registry.close(); + }); + + 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(); + + await expect(openRegistry(databasePath)).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); + }); + + 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) => { + 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(); + + await expect(openRegistry(databasePath)).rejects.toBeInstanceOf( + UnsupportedManagedRegistryVersionError, + ); + }, + ); + + 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")); + await (await openRegistry(databasePath)).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 new file mode 100644 index 0000000000..80dc7e6446 --- /dev/null +++ b/packages/stack/src/managed.ts @@ -0,0 +1,32 @@ +export * from "./managed/identity.ts"; +export * from "./managed/ids.ts"; +export * from "./managed/model.ts"; +export * from "./managed/paths.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 { ManagedStackRepository } from "./managed/repository.ts"; +export type { + ClaimManagedOperationFailure, + ClaimManagedOperationInput, + ClaimManagedOperationResult, + ManagedStackRepositoryShape, + OwnedManagedStackFailure, + PrepareOrdinaryStackFailure, + PrepareOrdinaryStackInput, + PrepareOrdinaryStackResult, + ReconcileManagedOperationFailure, + ReconcileManagedOperationResult, + UpdateManagedStackFailure, + UpdateManagedStackInput, +} from "./managed/repository.ts"; +export type { + CreateManagedStackServiceOptions, + MakeManagedStackServiceOptions, + ManagedStackLayerFailure, + ManagedStackServiceHandle, + ProvisionOrdinaryStackRequest, + ReconcileAbandonedOperationsRequest, +} from "./managed/create-service.ts"; 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 new file mode 100644 index 0000000000..fbb110bcf4 --- /dev/null +++ b/packages/stack/src/managed/create-service.ts @@ -0,0 +1,308 @@ +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, + 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?: ManagedStackRepositoryShape; + 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 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. + * + * 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 extends AsyncDisposable { + readonly stateRoot: string; + readonly repository: ManagedStackRepositoryShape; + provisionOrdinaryStack( + options: ProvisionOrdinaryStackRequest, + ): Promise; + inspectOrdinaryWorkspace(workspacePath: string): Promise; + inspectStack(stackId: string): Promise; + listStacks(options?: { + readonly includeTombstoned?: boolean; + }): Promise>; + 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; +} + +type InspectedManagedRuntime = "running" | "stopped" | "unknown"; + +const isInspectedRuntime = ( + answer: InspectedManagedRuntime | PromiseLike, +): answer is InspectedManagedRuntime => typeof answer === "string"; + +const managedStackServiceHandle = async ( + layer: Layer.Layer, +): Promise => { + const runtime = ManagedRuntime.make(layer); + // 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); + + /** + * 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 run( + service.provisionOrdinaryStack({ + workspacePath: options.workspacePath, + stackName: options.stackName, + configuration: options.configuration, + initialize: + initialize === undefined + ? undefined + : (stack) => fromCallback(() => initialize(stack), isFinished), + validate: + validate === undefined + ? undefined + : (stack) => fromCallback(() => validate(stack), isFinished), + }), + ); + }, + inspectOrdinaryWorkspace: (workspacePath) => + 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 run( + service.deleteStack(stackId, { + stop: + stop === undefined ? undefined : (stack) => fromCallback(() => stop(stack), isFinished), + }), + ); + }, + reconcileAbandonedOperations: (options) => { + const inspectRuntime = (stack: ManagedStackRecord, operation: ManagedOperationRecord) => + fromCallback(() => options.inspectRuntime(stack, operation), isInspectedRuntime); + return run( + service.reconcileAbandonedOperations( + options.force === undefined + ? { inspectRuntime, startedBefore: options.startedBefore } + : { inspectRuntime, force: options.force }, + ), + ); + }, + pruneCheckoutLocations: (shouldPrune) => + 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 => + 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), + ); + +/** + * 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, + ); + }, + ), + ); + +/** + * 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. 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 = async ( + fileSystemLayer: Layer.Layer, + options: MakeManagedStackServiceOptions, +): Promise => { + 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 + * 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 = async ( + fileSystemLayer: Layer.Layer, + openRepository: ( + registryPath: string, + ) => 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); + return managedStackServiceHandle( + managedStackLayerWith(fileSystemLayer, openRepository, { ...options, stateRoot }), + ); +}; 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/failure.ts b/packages/stack/src/managed/failure.ts new file mode 100644 index 0000000000..44af9d9485 --- /dev/null +++ b/packages/stack/src/managed/failure.ts @@ -0,0 +1,50 @@ +/** + * 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`. + * + * 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: + * + * ```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 new file mode 100644 index 0000000000..8e9313472d --- /dev/null +++ b/packages/stack/src/managed/identity.ts @@ -0,0 +1,174 @@ +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, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; +import { assertManagedUuid, createManagedUuid } from "./ids.ts"; +import { errorCode } from "./error-code.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. + * + * 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 = ( + 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) { + 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({ message: `${field} must be an opaque UUID` }); + } + return assertManagedUuid(fieldValue, field); +}; + +const decodeIdentity = (content: string): OrdinaryWorkspaceIdentity => { + let value: unknown; + try { + value = JSON.parse(content); + } catch (cause: unknown) { + throw new InvalidManagedIdentityError({ + message: `The ordinary workspace identity is not JSON: ${cause}`, + }); + } + if (typeof value !== "object" || value === null) { + 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({ + message: `Unsupported ordinary workspace identity version ${String(version)}`, + }); + } + return { + version, + projectId: identityField(value, "projectId"), + checkoutId: identityField(value, "checkoutId"), + contextId: identityField(value, "contextId"), + }; +}; + +export const canonicalizeOrdinaryWorkspacePath = ( + workspacePath: string, +): Effect.Effect => + 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, +): Promise => { + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + try { + return decodeIdentity(await readFile(markerPath, "utf8")); + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return undefined; + } + throw error; + } +}; + +export const readOrdinaryWorkspaceIdentity = ( + workspacePath: string, +): Effect.Effect => + failsWithIdentity(Effect.tryPromise({ try: () => readIdentity(workspacePath), catch: asRaised })); + +export interface EnsureOrdinaryWorkspaceIdentityResult { + readonly identity: OrdinaryWorkspaceIdentity; + readonly created: boolean; + readonly markerPath: string; +} + +/** + * 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, +): Promise => { + const existing = await readIdentity(workspacePath); + const markerPath = ordinaryWorkspaceIdentityPath(workspacePath); + if (existing !== undefined) { + return { identity: existing, created: false, markerPath }; + } + + const identity: OrdinaryWorkspaceIdentity = { + version: ORDINARY_WORKSPACE_IDENTITY_VERSION, + projectId: createManagedUuid(idFactory, "projectId"), + checkoutId: createManagedUuid(idFactory, "checkoutId"), + contextId: createManagedUuid(idFactory, "contextId"), + }; + + await mkdir(dirname(markerPath), { recursive: true }); + 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); + return { identity, created: true, markerPath }; + } catch (error: unknown) { + if (errorCode(error) !== "EEXIST") { + throw error; + } + const winner = await readIdentity(workspacePath); + if (winner === undefined) { + throw new InvalidManagedIdentityError({ + message: "Identity publication raced without a winning marker", + }); + } + return { identity: winner, created: false, markerPath }; + } finally { + await unlink(temporaryPath).catch(() => undefined); + } +}; + +export const ensureOrdinaryWorkspaceIdentity = ( + workspacePath: string, + idFactory: () => string = randomUUID, +): Effect.Effect => + failsWithIdentity( + Effect.tryPromise({ + try: () => ensureIdentity(workspacePath, idFactory), + catch: asRaised, + }), + ); diff --git a/packages/stack/src/managed/ids.ts b/packages/stack/src/managed/ids.ts new file mode 100644 index 0000000000..0f40ed2baa --- /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({ message: `${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 new file mode 100644 index 0000000000..9504661236 --- /dev/null +++ b/packages/stack/src/managed/model.ts @@ -0,0 +1,426 @@ +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"; + +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 InvalidManagedIdentityError extends Data.TaggedError("InvalidManagedIdentityError")<{ + readonly message: string; +}> { + readonly code = "INVALID_MANAGED_IDENTITY" as const; +} + +export class UnsupportedManagedRegistryVersionError extends Data.TaggedError( + "UnsupportedManagedRegistryVersionError", +)<{ + readonly found: number; + readonly supported: number; +}> { + readonly code = "UNSUPPORTED_MANAGED_REGISTRY_VERSION" as const; + + override get message(): string { + return `Managed registry version ${this.found} is unsupported; expected version ${this.supported}`; + } +} + +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 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; +}> { + readonly code = "MANAGED_INVALID_STACK_NAME" as const; + + override get message(): string { + return `Invalid managed stack name: ${this.stackName}`; + } +} + +export class InvalidManagedOwnerPidError extends Data.TaggedError("InvalidManagedOwnerPidError")<{ + readonly ownerPid: number; +}> { + readonly code = "MANAGED_INVALID_OWNER_PID" as const; + + override get message(): string { + return `Invalid managed operation owner pid ${this.ownerPid}`; + } +} + +export class InvalidManagedPortError extends Data.TaggedError("InvalidManagedPortError")<{ + readonly port: number; + readonly key: string; +}> { + readonly code = "MANAGED_INVALID_PORT" as const; + + override get message(): string { + return `Invalid managed port ${this.port} for ${this.key}`; + } +} + +export class ManagedStackNotFoundError extends Data.TaggedError("ManagedStackNotFoundError")<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_NOT_FOUND" as const; + + override get message(): string { + return `Managed stack ${this.stackId} was not found`; + } +} + +export class ManagedStackNotStoppedError extends Data.TaggedError("ManagedStackNotStoppedError")<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_NOT_STOPPED" as const; + + override get message(): string { + return `Managed stack ${this.stackId} must be safely stopped before deletion`; + } +} + +export class ManagedPendingStackUpdateError extends Data.TaggedError( + "ManagedPendingStackUpdateError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_PENDING_STACK_UPDATE" as const; + + override get message(): string { + return `Managed stack ${this.stackId} is still pending publication and cannot be reconfigured through an update`; + } +} + +export class ManagedOperationInProgressError extends Data.TaggedError( + "ManagedOperationInProgressError", +)<{ + readonly stackId: string; + readonly operation: ManagedOperationRecord; +}> { + readonly code = "MANAGED_OPERATION_IN_PROGRESS" as const; + + override get message(): string { + return `Managed stack ${this.stackId} already has an active ${this.operation.kind} operation`; + } +} + +export class ManagedOperationOwnershipError extends Data.TaggedError( + "ManagedOperationOwnershipError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_OPERATION_OWNERSHIP_MISMATCH" as const; + + override get message(): string { + return `The active operation for managed stack ${this.stackId} is owned by another caller`; + } +} + +export class ManagedPortReservationError extends Data.TaggedError("ManagedPortReservationError")<{ + readonly port: number; + readonly ownerStackId: string; +}> { + readonly code = "MANAGED_PORT_ALREADY_RESERVED" as const; + + override get message(): string { + return `Port ${this.port} is already reserved by managed stack ${this.ownerStackId}`; + } +} + +export class ManagedRunningStackPortChangeError extends Data.TaggedError( + "ManagedRunningStackPortChangeError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_RUNNING_STACK_PORT_CHANGE" as const; + + override get message(): string { + return `Managed stack ${this.stackId} cannot change ports while it continues to occupy them`; + } +} + +/** + * 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. + */ + override get message(): string { + return `${this.reason ?? UNSAFE_MANAGED_STACK_PATH_REASON}: ${JSON.stringify(this.path)}`; + } +} + +export class ManagedStackInitializationError extends Data.TaggedError( + "ManagedStackInitializationError", +)<{ + readonly stackId: string; + readonly cause: unknown; + readonly cleanupErrors: ReadonlyArray; +}> { + readonly code = "MANAGED_STACK_INITIALIZATION_FAILED" as const; + + override get message(): string { + return `Managed stack ${this.stackId} could not be initialized`; + } +} + +export class ManagedStackPublicationTimeoutError extends Data.TaggedError( + "ManagedStackPublicationTimeoutError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_STACK_PUBLICATION_TIMEOUT" as const; + + override get message(): string { + return `Timed out waiting for managed stack ${this.stackId} to be published`; + } +} + +export class ManagedAbandonedOperationError extends Data.TaggedError( + "ManagedAbandonedOperationError", +)<{ + readonly stackId: string; +}> { + readonly code = "MANAGED_OPERATION_REQUIRES_RECONCILIATION" as const; + + override get message(): string { + return `Managed stack ${this.stackId} has an abandoned operation that must be reconciled`; + } +} + +/** + * 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 + | DuplicateManagedPortKeyError + | InvalidManagedIdentityError + | InvalidManagedOwnerPidError + | InvalidManagedPortError + | InvalidManagedStackNameError + | ManagedAbandonedOperationError + | ManagedOperationInProgressError + | ManagedOperationOwnershipError + | ManagedPendingStackUpdateError + | ManagedPortReservationError + | ManagedRunningStackPortChangeError + | ManagedStackInitializationError + | ManagedStackNotFoundError + | ManagedStackNotStoppedError + | ManagedStackPublicationTimeoutError + | UnsafeManagedStackPathError + | UnsupportedManagedRegistryVersionError; + +/** + * Every `code` literal declared by a managed failure. + * + * `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 + * under Bun and Node alike, without pulling in a SQLite driver. + */ +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", + "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]; + +/** + * 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 + * 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", + 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 new file mode 100644 index 0000000000..a34838f3a6 --- /dev/null +++ b/packages/stack/src/managed/paths.ts @@ -0,0 +1,125 @@ +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { assertManagedUuid } from "./ids.ts"; +import { UnsafeManagedStackPathError, type ManagedStackPaths } from "./model.ts"; + +export interface ManagedStateRootOptions { + readonly stateRoot?: string; + readonly env?: Readonly>; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; +} + +const nonEmpty = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +}; + +const requireManagedStateRoot = (stateRoot: string): string => { + const trimmed = nonEmpty(stateRoot); + if (trimmed === undefined) { + throw new UnsafeManagedStackPathError({ + path: stateRoot, + reason: "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 + * 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. + * + * 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 => { + if (options.stateRoot !== undefined) { + return requireManagedStateRoot(options.stateRoot); + } + + const env = options.env ?? process.env; + const configuredHome = nonEmpty(env["SUPABASE_HOME"]); + if (configuredHome !== undefined) { + return join(resolve(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 = nonEmpty(env["LOCALAPPDATA"]); + return join( + localAppData === undefined ? join(userHome, "AppData", "Local") : resolve(localAppData), + "Supabase", + "managed", + ); + } + + const stateHome = nonEmpty(env["XDG_STATE_HOME"]); + return join( + stateHome === undefined ? join(userHome, ".local", "state") : resolve(stateHome), + "supabase", + "managed", + ); +}; + +/** + * 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"); + +export const managedStackPaths = (stateRoot: string, stackId: string): ManagedStackPaths => { + assertManagedUuid(stackId, "stackId"); + const root = join(stateRoot, "stacks", stackId); + return { + root, + data: join(root, "data"), + logs: join(root, "logs"), + runtime: join(root, "runtime"), + }; +}; + +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({ path: stackRoot }); + } + return actual; +}; + +export const ordinaryWorkspaceIdentityPath = (workspacePath: string): string => + join(workspacePath, ".supabase", "identity.json"); diff --git a/packages/stack/src/managed/repository-memory.ts b/packages/stack/src/managed/repository-memory.ts new file mode 100644 index 0000000000..4ddebecaa7 --- /dev/null +++ b/packages/stack/src/managed/repository-memory.ts @@ -0,0 +1,597 @@ +import { Effect } from "effect"; +import { + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + type ManagedCheckoutLocation, + type ManagedOperationRecord, + type ManagedRuntimeMetadata, + type ManagedStackConfiguration, + type ManagedStackRecord, +} from "./model.ts"; +import { failsWith } from "./failure.ts"; +import { + assertManagedOwnerPid, + assertManagedStackUpdatable, + compareManagedText, + managedStackOccupiesPorts, + reconcileManagedPortAssignments, + validateManagedPortAssignments, + type ClaimManagedOperationFailure, + type ClaimManagedOperationInput, + type ClaimManagedOperationResult, + type ManagedStackRepositoryShape, + type OwnedManagedStackFailure, + type PrepareOrdinaryStackFailure, + type PrepareOrdinaryStackInput, + type PrepareOrdinaryStackResult, + type ReconcileManagedOperationFailure, + type ReconcileManagedOperationResult, + type UpdateManagedStackFailure, + type UpdateManagedStackInput, +} 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. + * + * 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 = (): ManagedStackRepositoryShape => { + 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({ port: assignment.port, ownerStackId: 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({ port: assignment.port, ownerStackId: 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 => { + assertManagedOwnerPid(input.ownerPid); + 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) }; + }; + + 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 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 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({ stackId: stack.id }); + } + 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: "completed", + finishedAt: now, + }); + } + 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); + 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, + DuplicateManagedPortKeyError, + 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( + DuplicateManagedPortKeyError, + InvalidManagedPortError, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + ), + }), + listActiveOperations: (startedBefore) => + Effect.sync(() => + [...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) => + Effect.try({ + try: () => reconcileOperation(stackId, operationToken, lifecycle, now), + catch: failsWith( + DuplicateManagedPortKeyError, + 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; + }), + }; +}; diff --git a/packages/stack/src/managed/repository.ts b/packages/stack/src/managed/repository.ts new file mode 100644 index 0000000000..58b8d0bfc2 --- /dev/null +++ b/packages/stack/src/managed/repository.ts @@ -0,0 +1,305 @@ +import { Context, type Effect } from "effect"; +import { + DuplicateManagedPortKeyError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + ManagedStackNotFoundError, + type ManagedCheckoutLocation, + type ManagedOperationKind, + type ManagedOperationRecord, + type ManagedPortAssignment, + type ManagedStackConfiguration, + type ManagedStackLifecycle, + type ManagedStackPaths, + type ManagedStackRecord, + type OrdinaryWorkspaceIdentity, +} from "./model.ts"; +import type { DuplicateManagedIdentityError, ManagedOperationOwnershipError } 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; +} + +/** + * 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 }; + +/** Failures both adapters raise while registering an ordinary workspace stack. */ +export type PrepareOrdinaryStackFailure = + | DuplicateManagedIdentityError + | DuplicateManagedPortKeyError + | 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 = + | DuplicateManagedPortKeyError + | 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 = + | DuplicateManagedPortKeyError + | 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, + ) => Effect.Effect; + readonly updateStack: ( + input: UpdateManagedStackInput, + ) => Effect.Effect; + readonly listActiveOperations: ( + startedBefore?: string, + ) => Effect.Effect>; + readonly reconcileOperation: ( + stackId: string, + operationToken: string, + lifecycle: ManagedStackLifecycle, + now: string, + ) => 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"; + +/** + * 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: + * `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, +): 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; + }); +}; + +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 InvalidManagedPortError({ port: assignment.port, key: assignment.key }); + } + if (keys.has(assignment.key)) { + throw new DuplicateManagedPortKeyError({ key: assignment.key }); + } + if (numbers.has(assignment.port)) { + throw new ManagedPortReservationError({ port: assignment.port, ownerStackId: stackId }); + } + keys.add(assignment.key); + numbers.add(assignment.port); + } +}; + +export const reconcileManagedPortAssignments = ( + stack: ManagedStackRecord, + requested: ReadonlyArray | undefined, + targetLifecycle: ManagedStackLifecycle = stack.lifecycle, +): ReadonlyArray => { + if (requested === undefined) { + return stack.ports; + } + validateManagedPortAssignments(stack.id, requested); + const persisted = new Map(stack.ports.map((assignment) => [assignment.key, 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) && + !portNumbersEqual(stack.ports, reconciled) + ) { + throw new ManagedRunningStackPortChangeError({ stackId: stack.id }); + } + return reconciled; +}; + +/** + * 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({ stackId: stack.id }); + } + if (stack.status === "pending") { + throw new ManagedPendingStackUpdateError({ stackId: stack.id }); + } +}; diff --git a/packages/stack/src/managed/service.ts b/packages/stack/src/managed/service.ts new file mode 100644 index 0000000000..528101464a --- /dev/null +++ b/packages/stack/src/managed/service.ts @@ -0,0 +1,971 @@ +import { randomUUID } from "node:crypto"; +import { + Cause, + Context, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Schedule, +} from "effect"; +import { + DEFAULT_MANAGED_STACK_NAME, + InvalidManagedIdentityError, + InvalidManagedOwnerPidError, + InvalidManagedStackNameError, + ManagedAbandonedOperationError, + ManagedOperationInProgressError, + ManagedOperationOwnershipError, + ManagedStackInitializationError, + ManagedStackNotFoundError, + ManagedStackNotStoppedError, + ManagedStackPublicationTimeoutError, + UnsafeManagedStackPathError, + 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 { assertManagedUuid, createManagedUuid } from "./ids.ts"; +import { + assertManagedStackRoot, + managedStackPaths, + requireExplicitManagedStateRoot, +} from "./paths.ts"; +import { fromCallback, isBooleanAnswer } from "./callback.ts"; +import { errorCode } from "./error-code.ts"; +import { failsWith } from "./failure.ts"; +import { + assertManagedOwnerPid, + isUsableManagedOwnerPid, + ManagedStackRepository, + type ClaimManagedOperationFailure, + type OwnedManagedStackFailure, + type PrepareOrdinaryStackFailure, + type UpdateManagedStackFailure, +} from "./repository.ts"; + +export interface ManagedStackServiceOptions { + readonly stateRoot: string; + readonly idFactory?: () => string; + readonly clock?: () => Date; + readonly ownerPid?: number; + readonly publicationTimeoutMs?: number; + readonly publicationPollMs?: number; + readonly isProcessAlive?: (pid: number) => boolean | Promise; +} + +export interface ProvisionOrdinaryStackOptions { + readonly workspacePath: string; + readonly stackName?: string; + readonly configuration?: ManagedStackConfiguration; + /** + * 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 { + 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; + readonly dataReclamation: + | { readonly outcome: "removed" } + | { readonly outcome: "retained"; readonly error: unknown }; +} + +interface ReconcileAbandonedOperationsBaseOptions { + readonly inspectRuntime: ( + stack: ManagedStackRecord, + operation: ManagedOperationRecord, + ) => 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 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; + /** + * 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, 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 is reclaimed. + */ + readonly reclaimedStackIds: ReadonlyArray; + readonly retained: ReadonlyArray; + readonly skippedOperationIds: ReadonlyArray; + readonly failures: ReadonlyArray; +} + +/** 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 provisionOrdinaryStack: ( + options: ProvisionOrdinaryStackOptions, + ) => 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, + ) => 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) => 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, + ) => Effect.Effect; + readonly pruneCheckoutLocations: ( + shouldPrune: (location: ManagedCheckoutLocation) => Effect.Effect, + ) => Effect.Effect; +} + +const selectionForStack = (stack: ManagedStackRecord): ManagedStackSelection => ({ + projectId: stack.projectId, + checkoutId: stack.checkoutId, + contextId: stack.contextId, + stackId: stack.id, + stackName: stack.name, +}); + +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: [] }; + +/** + * 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; + +/** 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])?$/; + +/** + * 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 isUsableManagedOwnerPid} first. + */ +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + return errorCode(error) !== "ESRCH"; + } +}; + +/** + * 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), + }); + + 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(); + + 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 => + fromCallback(() => isProcessAlive(pid), isBooleanAnswer); + + /** + * 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), + recordUnlessInterrupted((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. + recordUnlessInterrupted(() => 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); + } + 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. + recordUnlessInterrupted(() => Effect.void), + Effect.flatMap(() => + finishOperationBestEffort(operation.stackId, operation.token, error), + ), + ); + }; + + 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; + }); + + // 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)), + ), + ), + ); + + /** + * 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( + // 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: Option.isNone, + }), + // 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.map((published) => published.value), + ); + + 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 })); + } + 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); + } + + const pending = prepared.stack; + const operation = prepared.operation; + // 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* () { + 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)); + } + } + // 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, + }), + ); + }), + ), + ), + ); + }); + + const inspectOrdinaryWorkspace = ( + workspacePath: string, + ): Effect.Effect => + Effect.gen(function* () { + const canonicalPath = yield* canonicalizeOrdinaryWorkspacePath(workspacePath); + const identity = yield* readOrdinaryWorkspaceIdentity(canonicalPath); + if (identity === undefined) { + return unregisteredWorkspace; + } + 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"); + // 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)), + ), + ), + ), + ); + }); + + 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( + recordUnlessInterrupted((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); + } + return { + recovered, + abortedStackIds, + reclaimedStackIds, + retained, + skippedOperationIds, + failures, + }; + }); + + 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 new file mode 100644 index 0000000000..bf4780e04b --- /dev/null +++ b/packages/stack/src/managed/sqlite-bun.ts @@ -0,0 +1,41 @@ +import { Database } from "bun:sqlite"; +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"; + +const openDatabase = (path: string): ManagedSqliteDatabase => { + hardenManagedRegistryFile(path); + const database = new Database(path, { create: true }); + return { + 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(); + }, + }; +}; + +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 new file mode 100644 index 0000000000..1e5b265d9f --- /dev/null +++ b/packages/stack/src/managed/sqlite-node.ts @@ -0,0 +1,41 @@ +import { DatabaseSync } from "node:sqlite"; +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"; + +const openDatabase = (path: string): ManagedSqliteDatabase => { + hardenManagedRegistryFile(path); + const database = new DatabaseSync(path); + return { + exec(sql) { + database.exec(sql); + }, + prepare(sql) { + const statement = database.prepare(sql); + return { + run(parameters = []) { + statement.run(...parameters); + }, + get(parameters = []) { + return statement.get(...parameters) ?? undefined; + }, + all(parameters = []) { + return statement.all(...parameters); + }, + }; + }, + close() { + database.close(); + }, + }; +}; + +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 new file mode 100644 index 0000000000..38a7856503 --- /dev/null +++ b/packages/stack/src/managed/sqlite.ts @@ -0,0 +1,1157 @@ +import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs"; +import { dirname } from "node:path"; +import { Duration, Effect, Layer, Schedule, Schema } from "effect"; +import { + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + InvalidManagedOwnerPidError, + InvalidManagedPortError, + MANAGED_REGISTRY_SCHEMA_VERSION, + ManagedOperationOwnershipError, + ManagedPendingStackUpdateError, + ManagedPortReservationError, + ManagedRunningStackPortChangeError, + 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 { + ClaimManagedOperationFailure, + ClaimManagedOperationInput, + ClaimManagedOperationResult, + 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; + +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 isSqliteBusy = (error: unknown): boolean => { + const code = errorCode(error); + if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") { + return true; + } + return error instanceof Error && /database is (?:busy|locked)/i.test(error.message); +}; + +const WAL_CONVERSION_RETRY_MS = 10; +const WAL_CONVERSION_RETRY_CEILING_MS = 100; +const WAL_CONVERSION_BUDGET_MS = 4_000; + +/** + * 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"); + }, + 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 { + database.exec("ROLLBACK"); + } catch { + // The original transaction error is more useful than a secondary rollback failure. + } +}; + +const migrateSchema = (database: ManagedSqliteDatabase): void => { + database.exec("BEGIN IMMEDIATE"); + try { + 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({ + found: version, + supported: MANAGED_REGISTRY_SCHEMA_VERSION, + }); + } + if (version === MANAGED_REGISTRY_SCHEMA_VERSION) { + database.exec("COMMIT"); + return; + } + 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), + 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, + 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, + 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) { + rollbackPreservingCause(database); + throw error; + } +}; + +/** + * 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"); + } catch (error: unknown) { + rollbackPreservingCause(database); + throw error; + } +}; + +/** + * `BEGIN`, the decision's statements, and `COMMIT` as one synchronous block. + * + * 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.try({ + try: () => runTransaction(database, "BEGIN IMMEDIATE", run), + catch: catchFailure, + }); + +const readTransaction = ( + database: ManagedSqliteDatabase, + run: () => A, +): Effect.Effect => + Effect.try({ try: () => runTransaction(database, "BEGIN", run), catch: neverFails }); + +const decodePort = (row: unknown): ManagedPortAssignment => ({ + key: getString(row, "key"), + port: getNumber(row, "port"), + intent: managedPortIntent(getString(row, "intent")), +}); + +const queryPorts = ( + database: ManagedSqliteDatabase, + stackId: string, +): ReadonlyArray => + database + .prepare("SELECT key, port, intent FROM ports WHERE stack_id = ? ORDER BY key") + .all([stackId]) + .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 decodeStackWithPorts = ( + row: unknown, + ports: ReadonlyArray, +): 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, + 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 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"), + 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, + lifecycle: ManagedStackLifecycle, +): void => { + validateManagedPortAssignments(stackId, ports); + if (managedStackOccupiesPorts(lifecycle)) { + 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({ + port: assignment.port, + ownerStackId: 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 => { + 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 }; +}; + +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 ?? [], + input.configuration.lifecycle ?? "stopped", + ); +}; + +const prepareOrdinaryStack = ( + database: ManagedSqliteDatabase, + input: PrepareOrdinaryStackInput, +): PrepareOrdinaryStackResult => { + 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 + (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({ 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); +}; + +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]); +}; + +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); +}; + +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); +}; + +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 + status = CASE WHEN status = 'pending' THEN 'active' ELSE status END, + 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 { 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); +}; + +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* initializeRegistry(database); + + return { + prepareOrdinaryStack: (input) => + Effect.flatMap(requireOwnerPid(input.ownerPid), () => + transaction( + database, + () => prepareOrdinaryStack(database, input), + failsWith( + DuplicateManagedIdentityError, + DuplicateManagedPortKeyError, + 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( + DuplicateManagedPortKeyError, + 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( + DuplicateManagedPortKeyError, + 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. + * + * 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, +): Layer.Layer => + Layer.effect( + ManagedStackRepository, + Effect.gen(function* () { + // 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(() => { + open.close(); + }), + ); + return yield* createSqliteManagedStackRepository(database); + }), + ); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts index f2316af105..6d4d02e961 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-memory.ts"; export { UnixHttpClient } from "./UnixHttpClient.ts";